目录
1. 类和对象认识
C语言是 面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题
C++是基于 面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成
对于同样一件事,比如说去超市买东西,对于C语言来说可能需要做如下工作
- 实现一个出门并且找到超市的函数
- 实现一个在超市里挑东西的函数
- 实现一个结账的函数
- ...
对于C++而言,并不关注是怎么完成买东西这个过程的,而是关注在这个过程中有几类"人"
- 对于买东西的人,他需要知道如何找到超市,需要知道要买什么东西
- 对于超市,它需要有商品,需要提供服务员引导,需要提供结账的服务
因此C++并不关注流程,而是关注每一个对象在这个流程中完成什么工作
2. 类的引入
C语言结构体中只能定义变量,在C++中,结构体内不仅可以定义变量,也可以定义函数
在C语言中,栈的结构体定义内只能有变量,函数不能在结构体内部
// C语言实现的栈
typedef int DataType;
struct Stack {
DataType* a;
int top;
int capacity;
};
void StackInit(struct Stack* ps);
void StackPush(struct Stack* ps, DataType x);
void StackPop(struct Stack* ps);
void StackInit(struct Stack* ps){}
void StackPush(struct Stack* ps, DataType x){}
void StackPop(struct Stack* ps){}
int main()
{
struct Stack s;
StackInit(&s);
StackPush(&s, 10);
StackPop(&s);
return 0;
}
由于C++兼容C,因此C++内可以用继续用struct来定义结构体,但C++是面向对象的,C++的结构体内也可以定义函数,这样的结构体是在C的基础上升级成了类
// 成员变量和成员函数可以在同一个结构体内
struct Stack
{
void Init(size_t cap)
{
a = (DataType*)malloc(sizeof(DataType) * cap);
if (nullptr == a)
{
perror("malloc申请空间失败");
return;
}
capacity = cap;
top = 0;
}
void Push(DataType x)
{
if (top <= capacity - 1)
{
a[top++] = x;
}
else
{
// ...
}
}
void Pop()
{}
DataType* a;
size_t top;
size_t capacity;
};
int main()
{
Stack s;
s.Init(4);
s.Push(1);
s.Push(2);
s.Pop();
return 0;
}
上面结构体的定义,在C++中更喜欢用class来代替
3. 类的定义
类的定义格式如下
class className
{
// 类体:由成员函数和成员变量组成
}; // 一定要注意后面的分号
class为定义类的关键字,ClassName为类的名字,{}中为类的主体,注意类定义结束时后面分号不能省略
类体中内容称为类的成员:
- 类中的变量称为类的属性或成员变量
- 类中的函数称为类的方法或者成员函数
类的两种定义方式:
- 声明和定义全部放在类体中,需注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理
- 类声明放在.h文件中,成员函数定义放在.cpp文件中,注意:成员函数名前需要加类名::
// 定义方式一
class Student
{
public:
void Showinfo() // 成员函数,显示学生信息
{
// ...
}
public:
char* _name; // 成员变量,姓名
char* _sex; // 成员变量,性别
size_t age; // 成员变量,年龄
};
一般情况下,更建议采用函数声明与定义分离的写法,便于查看类内部功能
// 定义方式二
<student.h>
class Student
{
public:
void Showinfo(); // 成员函数,显示学生信息,这里只是声明
public:
char* _name; // 成员变量,姓名
char* _sex; // 成员变量,性别
size_t age; // 成员变量,年龄
};
_________________________________________________________________________________________
<student.cpp>
void Stduent::showinfo() // 函数的定义,显示学生信息,要加类名
{
// ...
}
命名规则建议
- 一般建议在成员变量的前面加上前缀_
- 函数名、类名等所有单词的首字母大写
- 变量首字母小写,后面单词首字母大写
4. 类的访问限定符及封装
C++实现封装的方式:用类将对象的属性与方法结合在一块,(就是把成员变量和成员函数都放在类的内部),让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用
4.1 访问限定符
C++的访问限定符有三种:
- public(公有)
- private(私有)
- protected (保护)
【访问限定符说明】
- public修饰的成员在类外可以直接被访问
- protected和private修饰的成员在类外不能直接被访问(此处protected和private是类似的)
- 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止
- 如果后面没有访问限定符,作用域就到 } 即类结束
- class的默认访问权限为private,struct为public(因为struct要兼容C)
代码一:函数showinfo和变量a均被public修饰,可以在类外被访问到
class Student
{
public:
void showinfo() // 成员函数,显示学生信息
{
cout << "I'm public, you can see me" << endl;
}
int a = 10;
};
int main()
{
Student zs;
zs.showinfo();
cout << zs.a << endl;
return 0;
}
代码二:函数showinfo和变量a分别被private和protected修饰,不能在类外访问
class Student
{
private:
void showinfo() // 成员函数,显示学生信息
{
cout << "I'm public, you can see me" << endl;
}
protected:
int a = 10;
};
int main()
{
Student zs;
zs.showinfo(); // “Student::showinfo”: 无法访问 private 成员(在“Student”类中声明)
cout << zs.a << endl; // “Student::a”: 无法访问 protected 成员(在“Student”类中声明)
return 0;
}
代码三:private的作用域遇到public就结束了
class Student
{
private:
void showinfo() // 成员函数,显示学生信息
{
cout << "I'm public, you can see me" << endl;
}
public:
int a = 10;
};
int main()
{
Student zs;
zs.showinfo(); // “Student::showinfo”: 无法访问 private 成员(在“Student”类中声明)
cout << zs.a << endl; // 可以访问a
return 0;
}
代码四:class的默认访问权限为private,struct为public,因此
class A
{
void showinfo()
{}
};
struct B
{
void showinfo()
{}
};
int main()
{
A a;
a.showinfo(); // “A::showinfo”: 无法访问 private 成员(在“A”类中声明)
B b;
b.showinfo(); // 正常访问
return 0;
}
注意:访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别
4.2 封装
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互
封装本质上是一种管理,让用户更方便使用类。比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件
对于计算机使用者而言,不用关心内部核心部件,比如主板上线路是如何布局的,CPU内部是如何设计的等,用户只需要知道,怎么开机、怎么通过键盘和鼠标与计算机进行交互即可。因此计算机厂商在出厂时,在外部套上壳子,将内部实现细节隐藏起来,仅仅对外提供开关机、鼠标以及键盘插孔等,让用户可以与计算机进行交互即可。
在C++语言中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用
5. 类的作用域
类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用 :: 作用域操作符指明成员属于哪个类域
class A
{
public:
void show(); // 函数声明
};
void A::show() // 函数定义,要加上类名
{
cout << "you got me" << endl;
}
int main()
{
A a;
a.show();
return 0;
}
6. 类的实例化
用类类型创建对象的过程,称为类的实例化
类就像一张图纸,本身并不为内部的变量开辟空间,只有当我们拿这个图纸(类)将房子(对象)创建出来,才会在计算机上真正生成一个我们要的有血有肉的对象
对于类内部的成员变量,都只是声明。对于变量而言,开空间就是定义,不开空间就是声明
int age; // 变量的定义
extern int age; // 变量的声明
class Person
{
public:
void PrintPersonInfo(); // 成员函数的声明
private:
char _name[20]; // 成员变量的声明
char _gender[3]; // 成员变量的声明
int _age; // 成员变量的声明
};
// 这里需要指定PrintPersonInfo是属于Person这个类域
void Person::PrintPersonInfo() // 成员函数的定义
{
cout << _name << " " << _gender << " " << _age << endl;
}
7. 类对象模型
7.1 如何计算类对象的大小
class A
{
public:
void PrintA()
{
cout << _a << endl;
}
private:
char _a;
};
int main()
{
cout << sizeof(A) << endl; // 1
return 0;
}
这里得到的类的大小为1 ,和对象的存储方式与结构体对齐紧密相关
7.2 类对象的存储方式猜测
🔶 存储方式一:对象中包含类的各个成员
对于一个类实例化出来的每个对象,都会持有自己的成员函数和成员变量,而每个对象中成员变量是不同的,但是调用同一份函数,如果按照此种方式存储,当一个类创建多个对象时,每个对象中都会保存一份代码,相同代码保存多次,浪费空间
🔶 存储方式二:对象中包含成员变量和成员函数的地址,代码只保存一份,在对象中保存存放代码的地址
🔶 存储方式三:只保存成员变量,成员函数存放在公共的代码段(与第二种的区别是对象内不保存函数的指针)
用下面的代码来测试究竟使用的是哪种存储方式
// 类中既有成员变量,又有成员函数
class A1 {
public:
void f1() {}
private:
int _a;
};
// 类中仅有成员函数
class A2 {
public:
void f2() {}
};
// 类中什么都没有---空类
// 没有成员变量的类对象,给1字节占位,不存储实际数据,标识对象存在
class A3
{};
int main()
{
cout << sizeof(A1) << endl; // 4
cout << sizeof(A2) << endl; // 1
cout << sizeof(A3) << endl; // 1
return 0;
}
可以看到,A1类的大小为4,说明成员函数不在A1对象的空间内,如果是方案一,A1的大小应该是一个int加一个指针,大小为12;同理方案二也是一样,因此实际上对象的存储方式是方案三——一个对象内只包含成员变量,而成员函数则在公共代码区,并不在对象内也没有指针
注意空类的大小,空类比较特殊,编译器给了空类一个字节来唯一标识这个类的对象
和普通函数一样,当需要访问成员函数时,在编译链接时,通过符号解析和重定位找到函数的地址,再call成员函数
用下面的一个类来研究成员函数在内存中的位置
class A
{
public:
void func()
{}
private:
int _a;
};
int main()
{
A a1;
A a2;
a1.func();
a2.func();
return 0;
}
得到的main函数汇编如下:
000000000040064d <main>:
40064d: 55 push %rbp
40064e: 48 89 e5 mov %rsp,%rbp
400651: 48 83 ec 20 sub $0x20,%rsp
400655: 48 8d 45 f0 lea -0x10(%rbp),%rax
400659: 48 89 c7 mov %rax,%rdi
40065c: e8 65 00 00 00 callq 4006c6 <_ZN1A4funcEv>
400661: 48 8d 45 e0 lea -0x20(%rbp),%rax
400665: 48 89 c7 mov %rax,%rdi
400668: e8 59 00 00 00 callq 4006c6 <_ZN1A4funcEv>
40066d: b8 00 00 00 00 mov $0x0,%eax
400672: c9 leaveq
400673: c3 retq
符号表为:
mbol table '.symtab' contains 20 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS obj.cpp
2: 0000000000000000 0 SECTION LOCAL DEFAULT 2
3: 0000000000000000 0 SECTION LOCAL DEFAULT 4
4: 0000000000000000 0 SECTION LOCAL DEFAULT 5
5: 0000000000000000 1 OBJECT LOCAL DEFAULT 5 _ZStL8__ioinit
6: 0000000000000000 0 SECTION LOCAL DEFAULT 6
7: 0000000000000027 61 FUNC LOCAL DEFAULT 2 _Z41__static_initializati
8: 0000000000000064 21 FUNC LOCAL DEFAULT 2 _GLOBAL__sub_I_main
9: 0000000000000000 0 SECTION LOCAL DEFAULT 7
10: 0000000000000000 0 SECTION LOCAL DEFAULT 10
11: 0000000000000000 0 SECTION LOCAL DEFAULT 11
12: 0000000000000000 0 SECTION LOCAL DEFAULT 9
13: 0000000000000000 0 SECTION LOCAL DEFAULT 1
14: 0000000000000000 10 FUNC WEAK DEFAULT 6 _ZN1A4funcEv
15: 0000000000000000 39 FUNC GLOBAL DEFAULT 2 main
16: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _ZNSt8ios_base4InitC1Ev
17: 0000000000000000 0 NOTYPE GLOBAL HIDDEN UND __dso_handle
18: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _ZNSt8ios_base4InitD1Ev
19: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND __cxa_atexit
可以看到,第14行func的Ndx=6,说明对于func的引用是一个.rel.text节的条目,需要引用一个.text节的函数,汇编中也能看到两次call的是同一个地址4006c6,而这个程序的.text节从400560-400744,所以可以确定func是位于公共代码段的一个函数
12 .text 000001e2 0000000000400560 0000000000400560 00000560 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
13 .fini 00000009 0000000000400744 0000000000400744 00000744 2**2
因此,下面的程序实际上是能够正常运行的
class A
{
public:
void func(){}
};
int main()
{
A* ptr = nullptr;
ptr->func(); // 这里虽然是一个空指针,但并不会去对象内找func,而是在公共代码段找func,因此能找到,程序正常运行
return 0;
}
7.3 结构体内存对齐规则
类的成员函数不进入对象内存的计算,因此成员变量和结构体的对齐规则相同
结构体内存对齐规则:
- 第一个成员在与结构体偏移量为0的地址处
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处
注意:对齐数 = 编译器默认的一个对齐数与该成员大小的较小值
VS中默认的对齐数为8 - 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍
- 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍
8. this指针
8.1 this指针的引出
首先定义一个日期类(Date)
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;
d1.Init(2008, 1, 1);
Date d2;
d2.Init(2008, 1, 2);
d1.Print();
d2.Print();
return 0;
}
对于上述类,有这样的一个问题:
我们已经知道,类中的成员函数在公共代码段,那么两个对象去调用同一个函数时,所用到的是同一份代码,因此必须要有一种机制来让成员函数能够区分出不同的对象,比如:
Date类中有 Init 与 Print 两个成员函数,函数体中没有关于不同对象的区分,那当d1调用 Init 函数时,该函数是如何知道应该设置d1对象,而不是设置d2对象呢?
C++中通过引入this指针解决该问题,即:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成
编译器会分别对Init和Print进行处理
void Init(Date* const this, int year, int month, int day)
{
this->_year = year;
this->_month = month;
this->_day = day;
}
void Print(Date* const this)
{
cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
}
同时在main函数内部也会对调用进行处理
int main()
{
Date d1;
d1.Init(&d1, 2008, 1, 1);
Date d2;
d2.Init(&d2, 2008, 1, 2);
d1.Print(&d1);
d2.Print(&d2);
return 0;
}
其实对象在每次调用成员函数时,都会隐式地把自己的地址作为形参传给该成员函数,成员函数用一个this指针来接收,此后在成员函数内部所有需要用到成员变量的地方,都会隐式地加上this指针,它指明了这个成员变量是某个已经实例化的对象中的变量,这样就能把不同的对象区分开来
C++语法规定,在实参和形参的位置不能显式地传递和接收this指针,但可以在函数内部可以使用this指针(即使不使用也会右编译器自动增加),但this指针不能被修改,因为this指针被const所修饰,但this指针指向的内容(成员变量)可以被修改
考虑下面的代码,这个程序会崩溃,原因是在PrintA函数中_a是通过this->_a找到的,而this为nullptr
class A
{
public:
void PrintA()
{
cout << _a << endl; // 空指针解引用
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->PrintA();
return 0;
}
this指针存放在栈上,因为this是一个形参,也可能被编译器所优化,存放在寄存器内
对于下面的程序进行分析
class A
{
public:
void PrintA()
{
cout << this << endl;
}
private:
int _a;
};
int main()
{
A aa;
aa.PrintA();
return 0;
}
objdump得到main函数的汇编代码
00000000004007ad <main>:
4007ad: 55 push %rbp
4007ae: 48 89 e5 mov %rsp,%rbp
4007b1: 48 83 ec 10 sub $0x10,%rsp
4007b5: 48 8d 45 f0 lea -0x10(%rbp),%rax // 将帧寄存器-16处的地址传给寄存器%rax
4007b9: 48 89 c7 mov %rax,%rdi // 将%rax里的地址传给%rdi作为函数调用的第一个参数
4007bc: e8 59 00 00 00 callq 40081a <_ZN1A6PrintAEv> // call PrintA函数
4007c1: b8 00 00 00 00 mov $0x0,%eax
4007c6: c9 leaveq
4007c7: c3 retq
前三行是为main函数开辟栈帧,第四行lea指令加载对象aa的有效地址到%rax中,第五行%rax里的地址传给%rdi作为函数调用的第一个参数,第六行调用PrintA
接下来我们看看PrintA里面是如何保存this(%rdi里的值)的
000000000040081a <_ZN1A6PrintAEv>:
40081a: 55 push %rbp
40081b: 48 89 e5 mov %rsp,%rbp
40081e: 48 83 ec 10 sub $0x10,%rsp
400822: 48 89 7d f8 mov %rdi,-0x8(%rbp)
400826: 48 8b 45 f8 mov -0x8(%rbp),%rax
40082a: 48 89 c6 mov %rax,%rsi
40082d: bf 60 10 60 00 mov $0x601060,%edi
400832: e8 59 fe ff ff callq 400690 <_ZNSolsEPKv@plt>
400837: be b0 06 40 00 mov $0x4006b0,%esi
40083c: 48 89 c7 mov %rax,%rdi
40083f: e8 5c fe ff ff callq 4006a0 <_ZNSolsEPFRSoS_E@plt>
400844: c9 leaveq
400845: c3 retq
400846: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
40084d: 00 00 00
观察到第四行,在PrintA内部,将%rdi里的值保存到了帧寄存器-8处,说明在linux下的this指针存放在栈上
8.2 this指针的特性
- this指针的类型:类类型* const,即成员函数中,不能给this指针赋值
- 只能在“成员函数”的内部使用
- this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针
- this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递