引言
在C语言中,我们用结构体来描述一个复杂的对象,这个对象可能包括许多的成员,如用结构体描述一个学生的成绩,或者描述一个日期等。
cpp
struct Date
{
int _year;
int _month;
int _day;
};
如上是一个描述日期的结构体定义,里面可以有年、月、日这些成员,但是不能在里面有函数的声明或定义,这就使得和这个日期对象有关的函数需写在外部,在命名时就需要防止冲突。而且C语言的结构体对成员变量的保护不到位,可以随意访问对象的成员变量,非常不安全。因此,C++在兼容C语言 struct 的用法的同时将它升级为了类,并且C++喜欢用 class 关键字来定义类。
类的定义
cpp
class 类名
{
// 类体:由成员函数和成员变量组成
}; // 末尾分号不能落下
类体中的内容称为类的成员:类中的变量称为类的属性或成员变量; 类中的函数称为类的方法或者 成员函数。
cpp
struct ListNode
{
//struct ListNode* next; // C语言写法
ListNode* next; // C++可以这样用
int val;
};
类名就是类型,所以不需要再像C语言一样加上 struct 来表示类型。
接下来我们来定义一个简单的日期类:
cpp
class Date
{
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
int _year;
int _month;
int _day;
};
我们在类中写了一个 Init 函数用来初始化我们的对象,我们试着用这个类来实例化对象。
cpp
int main()
{
Date d1;
d1.Init(2024, 1, 22);
return 0;
}
用类的类型创建对象的过程,称为类的实例化。可以把类理解为设计图纸,用类实例化对象就是用图纸创建出具体的实物。但是目前我们的程序是有问题的,这个函数运行不了,因为目前这个函数是私有的,在类外面无法访问,这涉及到访问限定符的问题。
访问限定符
访问限定符包括:public(公有)、private(私有)、protected(保护),public 修饰的成员在类外可以直接被访问,而 private 和 protected 修饰的成员在类外不能直接被访问,现阶段暂时认为private 和 protected 没有什么区别。由于 struct 需要兼容C语言的用法,因此类中没使用访问限定符时默认是共有的,而 class 默认是私有的,因此我们上面那段代码无法运行。
cpp
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
加上访问限定符让成员函数的访问权限为公有就能运行了,成员变量一般都定义为私有。值得注意的是,如果成员函数在类中定义,编译器可能会将其当成内联函数处理,因此可以将较短的函数定义在类中,而较长的函数可以将声明与定义分离。
如果要将成员函数的声明和定义分离,在类外定义函数时需要在函数名字前使用域作用限定符指定类名,因为类定义了一个新的作用域。如下所示:
cpp
class Date
{
public:
void Init(int year, int month, int day);
private:
int _year;
int _month;
int _day;
};
void Date::Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
指定类名相当于告诉编译器这个函数是哪个类的,不然在涉及成员变量时编译器找不到这个变量的定义。
类的实例化
类是对对象进行描述的,就像一张设计图纸,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它。在类中定义的成员变量都只是声明,没有内存空间。只有当使用这个类去实例化对象的时候,它们才会作为该对象的专属成员变量而定义,也就是有了空间。
一个类可以实例化出多个对象,那它们是不是都有专属于自己的成员变量呢?是的,来看这个例子
cpp
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void print() { cout << _year << " " << _month << " " << _day << endl; }
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1, d2;
d1.Init(2024, 1, 22);
d2.Init(2023, 1, 22);
d1.print();
d2.print();
return 0;
}
这里我们用 Date 类实例化了两个对象比对它们进行初始化,然后借助 print 函数打印了它们各自的成员变量,很明显它们都有各自的成员变量。可是在调用函数时明明没有传参,它们的成员变量是怎么被准确找到的呢?这就是 this 指针的功劳了。
this 指针
其实每个"非静态的成员函数"的第一个参数都是一个隐藏的 this 指针,该指针指向的是当前对象(函数运行时调用该函数的对象)。在函数体中所有涉及"成员"的操作,都是通过该指针去访问。只不过这个过程我们看不到,因为用户不需要传递该指针,编译器会自动帮我们传递与使用该指针。
cpp
class Date
{
public:
// 第一个形参是帮助理解,用户不能自己写
// 为了篇幅着想,此处 Init 函数只给出声明,定义上面有
void Init(Date* const this, int year, int month, int day);
void print(Date* const this)
{
// 在函数中显示使用 this 是可以的,大部分情况不需要,交给编译器
cout << this->_year << " " << this->_month << " " << this->_day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1, d2;
// 同样第一个实参用于帮助理解,用户不能自己传
d1.Init(&d1, 2024, 1, 22);
d1.print(&d1);
return 0;
}
通过这段代码你应该知道对象的成员变量如何被访问了,实际上是编译器在负重前行,默默地帮我们传递对象的地址,默默地使用 this 指针去访问成员变量。
注意我给出的 this 指针的类型,它被 const 所修饰,因此不能改变指向,也非常合理,要是哪位大牛在函数内把 this 指针改了可就不好玩了。
总结一下: this 指针的类型为:类类型* const,它是"成员函数"第一个隐含的指针形参,当对象调用成员函数时,自动将对象地址作为实参传递给this形参,并且只能在"成员函数"的内部使用,一般情况由编译器通过 ecx 寄存器自动传递,不需要用户传递。
类(对象)的大小
cpp
int main()
{
Date d1;
d1.Init(2024, 1, 22);
cout << sizeof(d1) << endl;
return 0;
}
依旧是上面的日期类,你认为对象 d1 的大小是多少呢?通过前面的学习,成员变量存储在对象中应该已经是大家的共识了,那么成员函数会不会存在对象里呢?我们运行下程序看看。
由结果可见,成员函数是不存在对象中的。这也很符合实际,一个类可以实例化出那么多对象,要是每个对象都存一次函数,那岂不是太浪费空间了。事实上,成员函数存放在公共的代码段,大家都能调用。
好了现在你已经知道一个类的大小就是该类中"成员变量"之和,并且要注意内存对齐。那么请问 A类的大小又是多少?
cpp
class A
{};
int main()
{
cout << sizeof(A) << endl;
return 0;
}
简单,没有成员变量,所以大小是0,是这样吗?那么如果我用这个类实例化一个对象,你怎么表示这个对象存在过呢?简单来说就是你怎么表示这个对象。因此,对于没有成员变量的类(就算它有成员函数也一样,对象中不存储成员函数),其实例化对象大小为 1 个字节,这个字节不存储有效数据,只是来唯一标识这个类的对象。
现在问题又来了,this 指针为什么不存在对象中呢?首先 this 指针是形参,形参一般都是存在栈上的,也就是函数栈帧中,函数结束它就销毁了。但在 vs 下,this 指针通常存储在 ecx 寄存器中,因为它需要频繁使用,而且本身大小较小,将其存放在寄存器中可以提高访问速度。
现在补充一下内存对齐的知识:
- 第一个成员变量存储在结构体偏移量为0的地址,即从结构体的起始位置开始。
- 其他成员变量要对齐到对齐数的整数倍的地址处。 注意:对齐数 = 编译器默认的一个对齐数与 该成员大小的较小值。 (VS中默认的对齐数为8)
- 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
- 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
有趣的程序
cpp
class A
{
public:
void Print()
{
cout << "Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
该程序可以正常运行,虽然 p 是空指针,但是并没有涉及到访问空指针的问题,因为成员函数不存放在对象中,通过 p 能调用函数只是因为 p 指向的对象是 A 类的,而 Print 函数也是 A 类的。随后在函数内也没有访问成员变量,因此程序没问题。如图所示:
这里 p 的作用主要有两个:第一,让编译器知道调用的是 A 类里的 Print 函数。第二,调用函数时顺便把对象的地址,也就是 p 本身传给了形参 this 指针。