前言
在学习C和C++的过程中,老师通常会笼统地说C++包含了C,也就是那句熟悉的话——C++是C的超集。这种说法虽然在某种程度上是正确的,但并不完全准确。
不可否认,C++确实包含了C的许多特性,例如指针、结构体和预处理器指令等。然而,将两种语言合并成一种显然是不合理的。C和C++虽然共享许多语法和概念,但它们的设计理念和应用场景有着显著的区别。或者说,在我看来,C和C++不过是关键字相似的两种语言,尤其是越深入学习,越能理解两种语言的设计理念的不同。
在C的世界里,它是一种高度简约的面向过程的语言。C语言以其精巧的设计和高效的执行著称。依靠指针,你可以自由操控硬件,直接访问内存地址,这使得C在系统编程和嵌入式开发中占据重要地位。C语言的编译器通常生成高度优化的机器码,确保程序的运行效率极高。
但是在C++的世界里,它是一种面向对象的语言。C++在C的基础上增加了许多高级特性,如类、继承、多态、模板和异常处理等。智能指针和引用等特性都在隐约划分着它与C的界限。相比C,C++多了许多限制,不断引入的新语法使得它背上沉重的包袱。类是C++与C最大的区别之一,许多新特性都是为了类的发展。由于进一步的抽象,C++的运行效率相比C来说要低很多,但开发效率却提高了不少。
相较于其他语言C++凭借其高运行效率和历史发展,成就了庞大的生态系统。直到今天,C++依靠着类和模板,在各大系统级编程中经久不衰。
封装
类究竟是什么?类就是一个默认权限为私有的结构体,不过这个结构体是c的结构体的升级版本。
在c中的结构体具有以下特点:
- 结构体在不实例化时,不占用内存,它是一种数据组合的方式
- 结构体的成员只能为变量
- 结构体成员在定义时不能被初始化
- 结构体的访问权限是公开的
而在c++中,结构体具有了进一步扩展,比如:
- 结构体的成员可以为函数
- 结构体成员允许在被定义时初始化
(C++11及以后)。 - 结构体访问权限为公开,但是可以设置不同权限
- ……
为了区别与c中的结构体,c++给具有默认私有权限的超级结构体重新定义了一个新的关键字——class(类),不过曾经的struct关键字依旧可以使用。
不过还有一个可以补充的点:从本质上讲,类只不过是开发者层次上的抽象,其实在底层依旧是一个结构体数据栈,至于所谓的成员函数,其实是一个包含this指针的普通全局函数。
c++示例
#include <iostream>
using namespace std;
class student{
public:
void func(int num);
private:
int num = 1314520;
};
void student::func(int num){
this->num = num;
}
int main(){
student a;
a.func(10);
}
汇编示例:
.file "main.cpp"
.text
.local _ZStL8__ioinit
.comm _ZStL8__ioinit,1,1
.align 2
.globl _ZN7student4funcEi
.type _ZN7student4funcEi, @function
;成员函数 void func()定义
_ZN7student4funcEi:
.LFB1731:
.cfi_startproc
endbr64
;函数栈预处理
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
;main将num的地址和传入的参数10分别放进了 rdi寄存器和esi寄存器,分别存储在func函数栈上
movq %rdi, -8(%rbp)
movl %esi, -12(%rbp)
;读取之前存入的变量(其实这个有点多余,算是优化不到位)
movq -8(%rbp), %rax
movl -12(%rbp), %edx
;将参数10放进num的地址对应的位置中
movl %edx, (%rax)
;之前五句代码其实完全可以缩减成movl %esi, (%rdi)一句代码,不过这里并不是本次讨论的重心,只是一个小小的吐槽
nop
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1731:
.size _ZN7student4funcEi, .-_ZN7student4funcEi
.globl main
.type main, @function
;main函数的实现
main:
.LFB1732:
.cfi_startproc
endbr64
;函数栈预处理
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
;分配栈空间16个字节
subq $16, %rsp
movq %fs:40, %rax
movq %rax, -8(%rbp)
;eax寄存器清零
xorl %eax, %eax
;私有变量num初始化,事实上在汇编中是不存在变量的概念的,一切都变量不过是对栈基指针偏移量的解析
movl $1314520, -12(%rbp)
;将num的地址加载到rax寄存器中
leaq -12(%rbp), %rax
;func()函数的参数传递为10
movl $10, %esi
;num的地址作为类的this指针指向的空间,同时作为参数传递到func函数中
movq %rax, %rdi
;调用成员函数,其实就是调用一个全局函数
call _ZN7student4funcEi
movl $0, %eax
movq -8(%rbp), %rdx
subq %fs:40, %rdx
je .L4
call __stack_chk_fail@PLT
.L4:
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1732:
.size main, .-main
.type _Z41__static_initialization_and_destruction_0ii, @function
_Z41__static_initialization_and_destruction_0ii:
.LFB2230:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
cmpl $1, -4(%rbp)
jne .L7
cmpl $65535, -8(%rbp)
jne .L7
leaq _ZStL8__ioinit(%rip), %rax
movq %rax, %rdi
call _ZNSt8ios_base4InitC1Ev@PLT
leaq __dso_handle(%rip), %rax
movq %rax, %rdx
leaq _ZStL8__ioinit(%rip), %rax
movq %rax, %rsi
movq _ZNSt8ios_base4InitD1Ev@GOTPCREL(%rip), %rax
movq %rax, %rdi
call __cxa_atexit@PLT
.L7:
nop
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE2230:
.size _Z41__static_initialization_and_destruction_0ii, .-_Z41__static_initialization_and_destruction_0ii
.type _GLOBAL__sub_I__ZN7student4funcEi, @function
_GLOBAL__sub_I__ZN7student4funcEi:
.LFB2231:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $65535, %esi
movl $1, %edi
call _Z41__static_initialization_and_destruction_0ii
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE2231:
.size _GLOBAL__sub_I__ZN7student4funcEi, .-_GLOBAL__sub_I__ZN7student4funcEi
.section .init_array,"aw"
.align 8
.quad _GLOBAL__sub_I__ZN7student4funcEi
.hidden __dso_handle
.ident "GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
还有另外一种方式可以比较直观的观察到类的数据和函数分离的情况:
class my_class
{
public:
void func();
};
void my_class::func(){
cout<<"hello world"<<endl;
}
int main(){
my_class* p = nullptr;
p->func();
}
执行结果如下:
hello world
这里在没有实例化对象的情况下,能调用成员函数。不过这个代码实际上是利用了一个编译器的bug,因为g++编译器是不检查指针所指向的地址的正确性的,这里使用一个空指针逃过了编译的语法检查。
类的权限
类具有三种不同的权限。
私有 private | 私有权限的作用是将类中的成员转为仅类自身可以访问,它是类的默认访问权限。 |
公有 public | 公有属性允许所有的外部操作对其进行访问 |
保护 protected | 保护权限通常与私有权限类似,它能阻止外部的访问,不过它通常被使用在继承方面,因为私有属性是不能被继承的,而保护允许被继承。 |
示例
class my_class
{
private:
int a;//私有成员
public:
int b;//公有成员
protected:
int c;//保护成员
};
成员函数
C++的成员函数是定义在类内部的函数,用于操作类的对象和访问类的成员变量。成员函数可以访问类的私有和保护成员。此外,成员函数自身也可以设置权限,它的权限特性与成员变量的权限特性一致。即:
public成员函数可以被类的外部访问。protected成员函数只能被类和其派生类访问。private成员函数只能被类内部访问。
所有的成员函数都可以在类的内部定义或者类的外部定义,几乎所有的成员函数都有this指针。不过通常不推荐函数定义写在类的内部,因为编译器通常会内联类的内部的函数,如果其他函数调用了这个类的成员,那么在编译阶段,会在将成员函数的代码复制到该函数的内部一起编译,让函数变得臃肿。而且由于定义声明放在一起,整个类会变得及其混乱,不利于开发者理解。
而调用方式也很简单,通过对象的 . 符号调用,比如 a.func()
普通成员函数
这是最普遍的一种成员函数,它携带this指针,可以自由访问类的成员。
示例:
class my_class
{
private:
int a;//私有成员
public:
void set(int a);//声明
};
//定义
void my_class::set(int a){
this->a = a;
}
或者
class my_class
{
private:
int a;//私有成员
public:
void my_class::set(int a){
this->a = a;
}
};
构造函数
在c语言中,我们常常设计了一个Init()函数对复杂的数据结构进行初始化,但是在c++中提出来构造函数的概念,可以去除显式调用初始化函数的繁琐流程,而且也可以杜绝开发者忘记进行初始化或者重复初始化造成的未定义行为。
构造函数最大的特点是它的函数名与类相同,并且没有返回值。构造函数分为默认构造和有参构造两种类型。
通常情况下,如果一个类中没有定义任何构造函数(包括有参构造),那么编译器就会选择生成一个默认构造,如果定了有参构造,编译则不是再生成默认构造。
默认构造
示例:
class my_class{
private:
int a;//私有成员
public:
my_class();//默认构造函数
};
my_class::my_class(){
cout<<"我是默认构造函数"<<endl;
}
int main(){
my_class a;
}
从这个示例中,可以看到定义了一个默认构造函数,当类实例化的时候,默认构造函数会被调用。如果我们将默认构造函数的定义存放到类的私用中,那么这个类就无法被实例化,并且代码编译也会报错。
有参构造
有参构造就是带有参数的构造函数。
示例:
class my_class{
private:
int a;//私有成员
public:
my_class(int a);
};
my_class::my_class(int a){
cout<<"我是有参构造函数"<<endl;
}
int main(){
my_class a(10);
}
它能在初始化的时候传入参数,有参构造函数是允许重载的,可以自己设置不通过参数类型的构造函数。尤其要注意的是,有参构造会导致编译器不会添加默认构造函数,如果需要默认构造,那么必须要显式定义。
调用构造函数的方式
在进行类的实例化的时候,其实就在同时调用构造函数,但是实际上这只是调用的一种方式被称直接初始化。而初始化的方法,大致是分为两种,另外一种则是拷贝初始化。
直接初始化
直接初始化往往实在创建一个对象后,编译器会对初始化格式匹配类中的构造函数的形式,然后调用该函数对类进行初始化。
拷贝初始化
拷贝初始化则是使用了拷贝构造函数对对象进行初始化,不过这个初始化存在一个缺陷,那就是由编译器生成的拷贝构造函数默认情况下是基于二进制复制,也就是说会将被拷贝到参数所有的信息复制到目标对象中,在多数情况下是可行的,但是在出现像动态内存的情况下则会导致两个实例之间出现数据冲突,所以需要自己定义一个深拷贝构造函数。(拷贝构造函数详解–>跳转)
关于我在总结拷贝初始化的一点插曲
因为在学习的时候,这种基础的知识并没有归根结底的去学习,而是只知其然不知其所以然,当时我觉得拷贝初始化这种策略不是理所当然的吗?但是当我回过头来总结的时候,我突然意识到了拷贝初始化和直接初始化之间存在巨大的不和谐感。
最首当其冲的一点就是形式:在定义中,老师总是说,出现了类的实例化就会一定会调用构造函数,当然这个是不可能同时发生的。在微观尺度下,是一个类被实例化完成后,也就是分配栈空间后,编译器会调用对应的构造函数。这两者是绝对连续的,中间不能添加任何无关的操作,这是c++标准中的规定。
但是当拷贝初始化概念出现后,一切都奇怪了起来,因为拷贝初始化会在实例化了一个类后,却转头去创建了另外一个匿名对象,中间居然没有匹配构造函数。两种初始化方式没有一个统一的理论,长期写代码或者心细的人应该能发现这是非常别扭的一种情况,因为在标准设计上,统一往往是一个标准最终的形态,就好像物理学家不断追求统一场理论一样,c++标准设计显然没有统一场理论那么艰难,所以我在验证这两种截然不同的初始化方式背后必然存在某种思想上的统一。
为此,我查阅大量的资料,却忽略了重要的一点,拷贝初始化基于拷贝构造函数,而拷贝构造函数是构造函数的一种,在c++标准中提到的是构造函数,并没有特指哪一类。那么一切都解释通了,实际上拷贝初始化确实是在实例化后仅仅跟着构造函数,不过这个构造函数的一个参数是自身,另外一个参数是待拷贝到类。而这个等号,或者说按照伪代码的写法写成这样更好理解,copy( A, func(B)) ,在实例化A之后,A作为参数传入,调用copy函数(拷贝构造函数),但是该函数的第二个参数,而它需要被func()函数返回,所以需要再调用func对获取第二个参数,实际上它们依旧在拷贝构造函数的世界里,不过由于第二个参数需要进行一个获取操作,造成了类的实例化和调用构造函数被打断的错觉。
不过除了这两个大类外,每一类都可以细分为现实调用构造函数和隐式调用构造函数。
在普通构造函数中,通常显式调用构造函数的方式如下:
my_class a;//默认构造
my_class a(10);//有参构造
但是隐式构造函数通过如下方式:
my_class a = 10;//有参构造
而拷贝构造恰恰相反,显式的拷贝构造是这样的:
my_class a(10);//初始化一个示例
my_class b(a);//将第一个示例作为参数创建第二个实例
而隐式则是比较常用的这种形式:
my_class a(10);//初始化一个示例
my_class b = a;//将第一个示例作为参数创建第二个实例
析构函数
在c语言中,常常会导致各种资源忘记回收的囧境,比如由于调用了动态内存分配却忘记释放内存而导致内存泄露,为了解决这一个问题,c++引入了一个重要的概念——析构函数。
析构函数是C++类中的一种特殊成员函数,它在对象的生命周期结束时自动调用,用于执行清理操作,例如释放资源、关闭文件、释放内存等。
析构函数在C++中是不能被重载的。析构函数的签名是固定的,它没有返回类型,也不接受任何参数。主要原因是:析构函数的主要目的是在对象的生命周期结束时执行清理操作,例如释放资源、关闭文件、释放内存等。由于析构函数是在对象被销毁时自动调用的,因此它不需要也不能接受任何参数。
示例:
#include <iostream>
using namespace std;
class my_class{
private:
int* p = NULL;
public:
my_class(int a);
~my_class();
};
my_class::my_class(int a){
this->p = new int;
*this->p = a;
cout<<"创建类"<<endl;
}
my_class::~my_class(){
delete this->p;
this->p = NULL;
cout<<"类释放"<<endl;
}
void func(){
my_class a(10);
return;
}
int main(){
func();
cout<<"main函数结束"<<endl;
return 0;
}
执行结果
创建类
类释放
main函数结束
拷贝构造函数
拷贝构造函数是C++中的一种特殊构造函数(最容易忽略),用于创建一个新对象作为现有对象的副本。它在以下情况下被调用:
- 通过值传递对象:当对象作为参数传递给函数时。
- 通过值返回对象:当对象作为函数的返回值时。
- 显式复制对象:当使用拷贝初始化(例如
MyClass obj2 = obj1;)时。
浅拷贝
通常情况下,如果你没有显式定义拷贝构造函数,编译器会为你生成一个默认的拷贝构造函数。默认拷贝构造函数执行浅拷贝,即逐成员复制对象的所有成员变量。
深拷贝
在某些情况下,浅拷贝可能不够用,例如当类包含指向堆区的指针时。此时,你需要定义一个自定义的拷贝构造函数来执行深拷贝。深拷贝会复制指针所指向的实际数据,而不仅仅是指针本身。
深拷贝的标准格式
类名( const 类名& 参数名)
示例:
#include <iostream>
using namespace std;
class my_class{
private:
int* a = nullptr;
public:
my_class(int a);
my_class(const my_class& other);
~my_class();
void print();
};
my_class::my_class(int a){
this->a = new int;
*this->a = a;
}
my_class::my_class(const my_class& other){
this->a = new int;
*this->a = *other.a;
}
my_class::~my_class(){
delete this->a;
this->a = nullptr;
}
void my_class::print(){
cout<<*this->a<<" ["<<this->a<<"]"<<endl;
}
int main(){
my_class a(10);
a.print();
my_class b = a;
b.print();
}
执行结果
10 [0x6503fbf7deb0]
10 [0x6503fbf7e2e0]
分析:
从这个例子中可以看到,两个对象的值虽然相等,但是实际上它们的地址是不同的,也不能相同。因为它们对应了两个不同的动态内存空间,如果相同,则会导致数据冲突。
核心点1:为什么拷贝构造函数格式中必须要有 引用符号 &
首先要注意到一点,拷贝函数发生在数据拷贝的时候,如果不使用引用,就会造成一个死循环:如果不使用引用,那么拷贝构造函数发生拷贝,那么就需要把外界的参数拷贝到自己的参数中,由于外界将需要将参数拷贝到形参上,那么就需要调用拷贝函数….然后就死循环了——为了拷贝我需要将参数拷贝到我的形参里再进行拷贝,那么怎么把值拷贝进我的形参里呢?那就使用拷贝函数,完美的逻辑闭环。所以从上述情况就能明确的知道,拷贝构造函数必须使用引用形参的方式。
核心点2:传入的参数不是一个类的实例吗?类的实例为什么可以访问私有成员?
因为这里有一个很多人都有的误区,类的封装针对的是类,也就说,外界不能访问类的内部,不同类之间不能访问类的内部。但是同一个类的不同实例(对象),是可以相互访问私有成员的。
核心点3:赋值操作函数和拷贝构造函数有什么区别?
这个是一个非常容易被误解的点,因为两者都涉及到了 “=” 符号,但是两者实际上有质的区别,核心是函数调用的时机,那么要如何区分呢?
首先要理解拷贝构造函数,它是构造函数的一类,构造函数是在类进行实例化的时候对类进行初始化操作的,那么它出现的时间点一定是发生了类的实例化。
而赋值操作函数的核心是赋值,赋值就意味着当前已经存在了一个对象,只有对象存在才能进行赋值。
举一个例子:
my_class func(){
my_class a;
return a;
}
int main(){
my_class b;
b = func();
}
这是=符号是赋值,因为b是一个已经存在的对象,赋值是对对象的修改。
my_class func(){
my_class a(10);
return a;
}
int main(){
my_class b = func();
}
这是拷贝构造,b是一个不存在的实体,它需要进行实例化,实例化需要构造函数。
静态成员函数
静态成员函数是 C++ 类中的一个重要概念。静态成员函数非常特殊,它不与任何对象实例关联,只属于类本身,也就意味着它没有办法使用类的this指针,它无法访问类中除了静态成员变量以外的成员。而正是它不与任何实例关联,所以它有一个很重要的特性——公有权限下它可以不经过实例化直接调用。
示例
MyClass::func(); // 通过类名调用
与静态成员高度相关的是静态成员变量。
静态成员变量和具有静态变量的特性,不过它更加特殊:
- 它不可以在类中初始化。
- 它不占用对象的内存。
- 公有权限下它不需要类被实例化也能直接被访问。
- 所有的类的实例共享一个静态变量
示例
#include <iostream>
using namespace std;
class MyClass {
public:
static int count; // 静态成员变量
MyClass() {
count++; // 每创建一个对象,count 增加
}
~MyClass() {
count--; // 每释放一个对象,count减少
cout<<"释放 count = "<<count<<endl;
}
static void displayCount() { // 静态成员函数
cout << "Count: " << count << endl;
}
};
// 在类外部初始化静态成员变量
int MyClass::count = 0;
int main() {
MyClass obj1; // 创建第一个对象
// 通过类名调用静态成员函数
MyClass::displayCount();
MyClass obj2; // 创建第二个对象
// 通过类名调用静态成员函数
MyClass::displayCount();
return 0;
}
执行结果
Count: 1
Count: 2
释放 count = 1
释放 count = 0
常量成员函数
常量成员函数是指在函数声明后面加上 const 关键字的成员函数。它们不能修改对象的任何成员变量(除了 mutable 变量)。
一个典型的形式:
void func() const;{
}
它的特点就是:
- 只能调用其他常量成员函数:常量成员函数只能调用其他常量成员函数,不能调用非常量成员函数,也就是说,它只能调用类中其他被const修饰的成员函数,否则会报错。
- 不能修改成员变量:常量成员函数不能修改对象的任何成员变量,除了被声明为
mutable的变量。
相对的还有常量成员变量,其特点是:
- 初始化:常量成员变量必须在构造函数的初始化列表中进行初始化,不能在类的其他地方进行赋值。
- 不可修改:一旦初始化,常量成员变量的值不能被修改。
- 只读属性:常量成员变量提供了只读属性,确保其值在对象的生命周期内保持不变。
但是,最重要的一点上面的第一点提到了,只能在初始化列表进行初始化,在定义的时候,是不能初始化的(初始化列表见–>跳转)。
扩展
单例化
单例模式(Singleton Pattern)是一种设计模式,旨在确保一个类只有一个实例,并提供一个全局访问点来访问该实例。单例模式在需要控制资源访问或管理全局状态时非常有用。
这种方式的核心主要是依赖访问权限和静态成员(静态成员详解–>跳转)的特性,以下举一个但是实现的方式,注意实现方式并不是唯一的。
#include <iostream>
using namespace std;
//单例类
class Singleton {
private:
int a;
// 私有化构造函数,让其只允许内部调用
Singleton(int a) {this->a = a;}
// 删除拷贝构造函数和赋值运算符,注意这里的的特了不是释放动态内存,而是告诉编译器,不允许进行该操作(这种语法在c++11后被引入)。
Singleton(const Singleton&) = delete; //禁用拷贝构造函数。
Singleton& operator=(const Singleton&) = delete; //禁用赋值运算符。
public:
// 提供一个静态方法来获取实例
static Singleton& getInstance(int a) {
static Singleton instance(a);
return instance;
}
//操作
void func() {
cout<<"这里是单例类,a = "<<this->a<<endl;
}
};
int main(){
Singleton& instance = Singleton::getInstance(10);
instance.func();
Singleton& instance2 = Singleton::getInstance(100);
instance2.func();
}
执行结果
这里是单例类,a = 10
这里是单例类,a = 10
这里会发现创建的两个值实际上是一个实例。
初始化列表
初始化列表是 C++ 中用于在构造函数中初始化成员变量的一种方式。它允许在构造函数体执行之前对成员变量进行初始化,其具有以下特点:
- 高效:初始化列表直接初始化成员变量,避免了在构造函数体内进行赋值操作,从而提高了效率。
- 必须性:对于常量成员变量、引用成员变量和没有默认构造函数的类类型成员变量,必须使用初始化列表进行初始化。
- 顺序:初始化列表的初始化顺序与成员变量在类中声明的顺序一致,而不是在初始化列表中的顺序。
初始化列表的语法如下:
class MyClass {
public:
int a;
const int b;
int& c;
MyClass(int x, int y, int& z) : a(x), b(y), c(z) {
// 构造函数体
}
};
或者说
class ClassName {
public:
ClassName(参数列表) : 成员变量1(初始值1), 成员变量2(初始值2), ... {
// 构造函数体
}
private:
成员变量类型1 成员变量1;
成员变量类型2 成员变量2;
...
};
示例
#include <iostream>
using namespace std;
class MyClass {
public:
int a;
const int b;
int& c;
// 构造函数,在初始化列表中初始化成员变量
MyClass(int x, int y, int& z) : a(x), b(y), c(z) {}
void display() const {
cout << "a: " << a << ", b: " << b << ", c: " << c << endl;
}
};
int main() {
int ref = 30;
MyClass obj(10, 20, ref);
obj.display();
return 0;
}
执行结果
a: 10, b: 20, c: 30
初始化列表是属于构造函数的一部分,可以将一个完整的构造函数看作三部分(从汇编的角度),第一部分是构造函数标签,第二部分就是初始化列表的执行,第三部分则是构造函数的函数体。三个部分从上往下。
在没有初始化列表的情况下就没有汇编代码,自然而然标签下面的就是函数体了。
对于列表初始化还存在一个疑点:
首先是列表初始化的定义是[ ClassName(参数列表) : 成员变量1(初始值1), 成员变量2(初始值2),… ] 。这个很好理解,就是将括号内部的值传入成员变量中,即便成员替换成类对象也很好理解,因为对象名+值,这个是构造函数的一种显式调用的方法,但是却能使用基类的构造函数进行初始化,直接调用类名(参数)这样的形式。
explicit
explicit 关键字是 C++ 中用于修饰构造函数和转换函数的一个重要关键字。它的主要作用是防止隐式类型转换,从而避免潜在的错误和意外行为,主要特点如下:
- 防止隐式类型转换:
- 当构造函数被
explicit修饰时,该构造函数不能用于隐式类型转换。例如,不能使用单参数构造函数进行隐式转换。 - 这意味着只能通过显式调用构造函数来创建对象,而不能通过赋值或其他隐式方式。
- 当构造函数被
- 适用于单参数构造函数:
explicit关键字通常用于修饰只有一个参数的构造函数,以防止隐式类型转换。- 例如,
explicit MyClass(int x);表示该构造函数只能通过显式调用来创建对象。
- 适用于转换函数(C++11 引入):
explicit关键字也可以用于修饰转换函数,以防止隐式类型转换。- 例如,
explicit operator int() const;表示该转换函数只能通过显式调用来进行类型转换。
示例:
#include <iostream>
using namespace std;
class MyClass {
public:
explicit MyClass(int x) : value(x) {}
int getValue() const {
return value;
}
private:
int value;
};
void printValue(const MyClass& obj) {
cout << "Value: " << obj.getValue() << endl;
}
int main() {
MyClass obj1(10); // 显式调用构造函数
// MyClass obj2 = 20; // 错误:不能进行隐式转换
printValue(obj1);
// printValue(30); // 错误:不能进行隐式转换
return 0;
}
在这个示例中:
MyClass的构造函数被explicit修饰,因此不能通过隐式转换来创建对象。- 只能通过显式调用构造函数来创建
MyClass对象,例如MyClass obj1(10);。 - 不能通过赋值或其他隐式方式来创建对象,例如
MyClass obj2 = 20;会导致编译错误。
成员变量的本质
这个是一个尤其重要的点,事实上,你写的类并不是给机器用的,而是给编译器看的。与之而来的一个容易忽略的问题是,类中定义的成员不过是一个空壳,事实上它们并不存在。举一个例子
#include <iostream>
using namespace std;
class A{
public:
int age;
A(int a){
this->age = a;
}
A(){
cout<<"aaaa"<<endl;
}
};
class B{
public:
B(int i):f(i){}
private:
B f;
};
int main(){
B b(10);
}
这个例子中,A的哪个构造函数会执行呢?如果看到了类中的 B f;恐怕会以为会执行默认构造函数。但是事实上并不会,因为在CPU看不到类,类在编译器中被肢解,所有的内存安排是编译器生成的。如果你执行这个代码,你会发现没有输出,而如果你看到了这个汇编的代码,就会明白:
main:
.LFB1740:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movq %fs:40, %rax
movq %rax, -8(%rbp)
xorl %eax, %eax
leaq -12(%rbp), %rax
;//这里可以看到,只将一个立即数传递给寄存器后,就传递了this指针,接下来直接调用了构造函数,根本没有生成所谓的A类对象,所以说,实际上这个B类中定义的A,根本就没有,而是在实例化为对象的阶段进行的有参构造。
movl $10, %esi
movq %rax, %rdi
call _ZN3BC1Ei
movl $0, %eax
movq -8(%rbp), %rdx
subq %fs:40, %rdx
je .L5
call __stack_chk_fail@PLT
.L5:
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1740:
根据汇编能清晰的看到,在定义B类时,private中的类A的实例对象是不存在的,那个位置只是声明了B类会用到A类,而非进行实例化。
继承
前言
什么是继承
不同权限的继承
不过继承这个概念有一个常见的误区,有些人会误认为父类被子类继承,仅仅继承公有和保护成员,而私有成员不会继承,但是这个是错误的。事实上,继承会让子类完全继承父类所有的成员包括私有成员,但是子类是没有权限访问父类的私有成员的。
继承方式
继承权限
继承权限一共有三种,分别是公有继承,保护继承和私有继承,所谓的继承权限其实是影响父类被继承后在子类中的权限。
公有继承
公有继承相当于将父类的原封不动的继承。如果父类中成员权限是公有,那么在子类也公有,如果是保护,那么在子类也是保护,如果是私有在子类也是私有。
标准格式为:
class son : public father
保护继承
保护继承则会将父类中公有和保护权限的成员,在子类中权限都转化为保护权限。
标准格式为:
class son : protected father
私有继承
私有继承会将父类中的公有与保护权限的成员,在子类中转换为私有成员的权限。
标准格式为:
class son : private father
总结
三种继承方式中,保护继承和私有继承会改变成员权限,但是要注意的一点是,只能改变父类保护和公有权限,对于父类的私有权限,是不能被继承方式修改权限的,不管如何继承,它都是被禁止访问的。
继承顺序
核心点1:如果子类继承了父类,那么子类和被继承的父类谁先被实例化?为什么呢?
在继承中,必须先实例化父类,然后再实例化子类,举一个例子:
#include <iostream>
using namespace std;
class father{
public:
father(){
cout<<"father被构造"<<endl;
}
~father(){
cout<<"father被析构"<<endl;
}
};
class son : public father{
public:
son(){
cout<<"son被构造"<<endl;
}
~son(){
cout<<"son被析构"<<endl;
}
};
int main() {
son a;
return 0;
}
执行结果
father被构造
son被构造
son被析构
father被析构
通过代码结果可以分析出,父类先被构造,然后再构造子类。那么为什么不能先构造子类呢?
因为子类是基于父类诞生的,子类中可能存在需要基于父类的成员进行计算的情况,如果在这个情况下子类先实例化,那么会因为当前父类不存在实体而出现未定义错误。而父类一定不会基于子类构造,所以统一设计为先构造父类再构造子类的方式更加合理。
举一个例子:
class father{
public:
int a;
father(){
a = 10;
}
};
class son : public father{
public:
int b;
son(){
b = a*a;
}
};
int main() {
son a;
return 0;
}
在这个例子中,可以看到,子类的参数是基于父类的参数进行初始化,如果此时父类没有被构造,那么子类初始化将会出现问题。
核心点2:析构函数的顺序是什么?
这个其实完全可以自己推理出来,首先从函数栈的角度,父类先实例化压入栈区,然后子类再实例化压入展区,在栈弹出的时候,那么一定是子类先弹出,再父类弹出,也就是先调用子类再调用父类的析构函数。
同名继承
在继承的过程,往往会遇到一种情况——父类的函数并不是我想要的,但是我的函数作用和这个函数一致,虽然写一个新函数是解决方法的一种,但是这会导致同作用的函数不断增多,显然违背了继承的最初观念,于是c++提供了一种语法可以支持在子类以同名函数重写父类的函数。
示例:
#include <iostream>
using namespace std;
class father{
public:
father(){
cout<<"father被构造"<<endl;
}
~father(){
cout<<"father被析构"<<endl;
}
void func(){
cout<<"我是father"<<endl;
}
};
class son : public father{
public:
son(){
cout<<"son被构造"<<endl;
}
~son(){
cout<<"son被析构"<<endl;
}
void func(){
cout<<"我是son"<<endl;
}
};
int main() {
son a;
a.func();
return 0;
}
执行结果
father被构造
son被构造
我是son
son被析构
father被析构
在这个代码的执行结果中,可以看到函数输出的是子类重写的func函数。虽然父类被子类重写,但是父类的同名函数并非就无法访问了,你可以通过声明的方式使用func。比如father::func()。
用上面的代码举例。在main函数中需要这样显式声明:
int main() {
son a;
a.func();//调用子类重写的func
a.father::func();//通过作用于显式声明调用father类中的func
return 0;
}
有一个点需要补充:子类继承后,就应该以子类为主体,就像孩子长大了要离开父母,这个时候孩子就是一个独立的个体了。有了这种思想,就还有一个点容易理解了,当父类出现了函数重载时,如果子类重写了其中一个函数,那么父类的所有同名函数全部都会被隐藏,除非被类名显式调用。也就是孩子独立了,有了自己的家庭,那么核心就要放在自己的家庭中了,比如平时一日三餐的买菜,在不强调的情况下,往往是给自己家里面买菜,而不是买菜后千里迢迢送到父母家中。
除了同名函数的继承,同样的也可以进行同名变量的继承,用法与同名函数完全一致。而对于特殊的静态成员变量和静态成员函数,和普通的变量与函数处理方法也是完全一致的。
多重继承
多重基础的方法
多重继承与普通继承没有什么区别,不过每一个被继承的对象可以单独声明继承权限,多个父类之间用,号链接,并且构造顺序从左到右最后子类进行构造,举一个例子:
#include <iostream>
using namespace std;
class mother{
public:
mother(){
cout<<"mother被构造"<<endl;
}
~mother(){
cout<<"mother被析构"<<endl;
}
void func(){
cout<<"我是mother"<<endl;
}
};
class father{
public:
father(){
cout<<"father被构造"<<endl;
}
~father(){
cout<<"father被析构"<<endl;
}
void func(){
cout<<"我是father"<<endl;
}
};
class son : public father ,public mother{
public:
son(){
cout<<"son被构造"<<endl;
}
~son(){
cout<<"son被析构"<<endl;
}
void func(){
cout<<"我是son"<<endl;
}
};
int main() {
son a;
return 0;
}
执行结果
father被构造
mother被构造
son被构造
son被析构
mother被析构
father被析构
同名冲突
在父类之间如果给自类中不存在同名函数,那么你可以像单继承一样操作。但是如果存在同名函数,比如例子中的func,此时就需要特别指明是哪一个类中继承下来的成员。由于实际开发中,通常是多个人合作,想size这种高频函数就会导致冲突,所以在实际开发中一般避免使用多继承。
菱形继承
假设我们有一个基本类Animal,它有一个成员函数makeSound()。然后我们有两个派生类Mammal和Bird,它们都继承自Animal。最后,我们有一个类Bat,它同时继承自Mammal和Bird。
在这个例子中,Bat类继承了两个makeSound()函数,一个来自Mammal,一个来自Bird。这就形成了一个菱形结构,因为Bat类通过两个不同的路径继承了Animal类。这种结构可能会导致二义性问题,因为编译器不知道应该调用哪个makeSound()函数。
形象的讲,就是一个函数被两个类继承变成了两个一模一样的函数,而两个类被同一个子类继承,那么这个子类就弄不明白了,因为有两个一模一样的函数编译器不知道该怎么做了。
这个情况很像之前提到的多重继承引发的二义性,不过这种情况更特殊,因为它虽然在最后一层的继承中makeSound()出现了二义性,但是实际上makeSound()都来自于类Animal,也就是本质上这个函数就是同一个,解决方法也很简单,那就是让编译器知道这两个函数其实就是同一个函数就可以了,于是由此诞生了一种方式——虚继承。
虚继承的概念
虚继承(virtual inheritance)是C++中的一种继承机制,用于解决多重继承中的菱形继承问题。菱形继承问题会导致基类的成员在派生类中被多次继承,从而引发数据冗余和不一致性,其主要特点为:
- 避免重复继承:
- 通过虚继承,虚基类在最派生类中只会被初始化一次,而不是每个派生类都初始化一次。这避免了基类成员的重复继承和初始化。
- 虚基表指针:
- 在虚继承中,每个派生类都会有一个指向虚基类的虚基表指针(
vptr)。这些指针在不同的派生类中是不同的,但它们最终指向同一个虚基类实例。
- 在虚继承中,每个派生类都会有一个指向虚基类的虚基表指针(
- 最派生类负责初始化:
- 虚基类的构造函数只能由最派生类负责调用。即使中间的派生类(如
B和C)继承了虚基类,它们也不会调用虚基类的构造函数。
- 虚基类的构造函数只能由最派生类负责调用。即使中间的派生类(如
虚基类是指被虚继承的基类。
标准格式为:
class 类名 : virtual 继承权限 父类类名
举例
class A {
public:
int value;
A(int v) : value(v) {}
};
class B : virtual public A { //虚基类
public:
B() : A(0) {}
};
虚继承解析
从发现问题到解决问题的思路来讲解,首先先抛出一个问题代码:
#include <iostream>
using namespace std;
class A {
public:
int value;
A(int v) : value(v) {}
};
class B : public A {
public:
B() : A(10) {}
};
class C : public A {
public:
C() : A(0) {}
};
class D : public B, public C {
public:
};
int main() {
D obj;
std::cout << obj.value << std::endl; // 输出10
return 0;
}
暂时忽略对D类的实现,先考虑D类中可能出现的情况,D类在实例化的过程中,发现了来自B类的value和来自C类的value,当它需要被调用value时,会导致系统不知道调用哪一个。
通过仔细的观察,实际上很容易发现,这两个value是同一个。但是被某种神秘的力量分化成了两个,那么就需要再次借助什么的力量将两个value合并成为一个。
先看一下正确的方法,后续会好理解一点:
#include <iostream>
using namespace std;
class A {
public:
int value;
A(int v) : value(v) {}
};
class B : virtual public A {
public:
B() : A(10) {}
};
class C : virtual public A {
public:
C() : A(0) {}
};
class D : public B, public C {
public:
D() : A(10), B(), C() {}
};
int main() {
D obj;
std::cout << obj.value << std::endl; // 输出10
return 0;
}
暂时忽略这个正确的代码,考虑如何解决,最直接的一种解决方案,那就是只生成一个value,让B类和C类中分别存储一个指针,指向这个value。这样就会发现B类和C类,就会共享同一个value空间。当然中间的过程肯定不是这么简单,我们暂时将这个操作假定为“神秘黑盒操作”,这个黑盒操作定义如下:在发生虚继承的时候,子类会获得一个黑盒特征码,将黑盒特征码进行神秘黑盒操作,可以获得找到value所在的空间。
核心点1:在例子中的D类如果是进行继承B类和C类,那么在实例化的时候,会从老老老…祖宗那里开始构造,那么在B类和C类发生对A的构造时就会出现重复,应该如何解决呢?
在这个核心点的问题很很重要,如果嵌套的层数越多,那么就会导致多次反复调用构造,这显然是不可取的,所以解决方案就是使用最终派生类负责初始化。换句话说,不管发生了多少层继承,只要你不是最后一层,那么你对A调用构造的权力就被剥夺了,只允许最后一层对老祖宗进行构造函数的调用。
但是注意,这并不影响中间层写调用老祖宗构造函数的代码,你写归你写,至于用不用就得编译器说了算,当然一般不会用,除非你是老祖宗最疼爱的孙子(最后一层),这也就避免了多次调用同一个构造函数的情况了。
class D : public B, public C {
public:
D() : A(10), B(), C() {}//由D类负责对A类的构造
};
核心点2:在D类实例化后,就有两个黑盒特征码了,那么如果要调用value,使用谁的黑盒特征码进行神秘黑盒操作获取到value呢?
这个是编译配置的,编译器会根据某种规则选择其中一个最高效的方式来获取value,因为实际上不管哪个特征码最终指向的是同一个value。
说完了大体框架,接下来就应该解开黑盒了。
之前简单概括为虚继承的类会产生一个指针指向value,这里是一种极简的思维的类比,在实际上,虚基础并不直接生成一个指向value的指针,而是会在内存的全局区生成一个虚基表,而虚继承生成的指针是指向这个虚基表的指针,也称为虚基表指针。
这里就会有人思考,虚基表是什么?为什么要一个指针指向虚基表。
虚基表本质上讲就像是一个数组,这个数组存放的是偏移量。它的作用就像是一个目录,比如我生成的对象基地址是0x00,value的值是0x10,那么这个虚基表中存放的就是偏移量10,当希望访问到value的时候,只需要通过虚基表指针访问虚基表,根据想访问的成员从虚基表中查询到这个成员对应的偏移量是10 ,然后用10加上这个对象的基地址,就可以获取到value了。(补充,这是一种设计方式,但是我并没有找到资料明确的表示基址是最派生类的对象的首地址。所以最后的结论是:偏移量不一定是以对象的首地址为基址的,这个取决于编译器。)
这里就能体会出了,在之前的分析中,所谓的黑盒特征码就是虚基表指针,而神秘黑盒操作便是通过虚基表指针查询偏移量,再根据偏移量获取到value的过程。
由于菱形继承的时候,最终派生类会继承多个父类。所以每个父类的虚基表和虚基表指针都是各不相同的。
核心点3:如果在多重继承发生后,最终派生类的内存布局是怎么样的?

在内存布局中有几个比较重要的点:
- 父类的虚基表指针被编译生成,而且存放在类的首个成员的之前。
- 最终派生类的父类实例中不存在被虚继承的成员,这个成员被存放在最终派生类的最底部。
多态
多态(Polymorphism)是面向对象编程中的一个核心概念,指的是同一个接口可以有不同的实现方式。多态性使得程序可以在运行时决定调用哪个具体的实现,从而提高代码的灵活性和可扩展性。
多态的主要场景是设计公用接口,它可以通过一个统一的接口在不同的情况下,访问不同的派生类中的重写的函数。
不过需要注意的是:
- 多态性通常通过抽象类和纯虚函数来实现。抽象类不能实例化,只能作为基类使用,而纯虚函数则必须在派生类中实现。
- 虽然多态性提供了很大的灵活性,但也会带来一些性能开销,特别是在频繁调用虚函数的情况下。因此,在性能敏感的场合,需要权衡使用多态性的利弊。
虚函数
虚函数(Virtual Function)是一个在基类中使用关键字 virtual 声明的成员函数。它允许派生类对其进行重写,从而实现运行时多态性。
标准形式如下:
virtual 返回值 函数名 //基类
返回值 函数名 override //派生类
例如:
virtual void func(); //基类
void func() override; //派生类
要注意的一点是,派生类的override关键字并不是强制的,它是显式提醒编译器这是一个虚函数重写,虽然这个关键字可以去掉,但是强烈建议写上,因为如果开发者错误的拼写了重写的虚函数的名称,导致和基类的函数签名不同,那么编译器会报错。
核心点1:为什么要引入虚函数
虚函数是为了让父类对子类重写的函数进行访问,举一个情景例子,假设你在开发一个游戏,你需要为游戏的人物划分出不同的职业,比如枪手,剑客等等,它们具有相同的操作逻辑,只不过特性不一样。
#include <iostream>
using namespace std;
class character{
public:
void func(){
cout<<"我是人物"<<endl;
}
};
class swordsman : public character{
public:
void func(){
cout<<"我是剑客"<<endl;
}
};
int main(){
character a;
a.func();
}
在这个例子中,构建了一个人物类和一个剑客类,但是存在一个严重的问题:人物类无法访问到剑客类。也就是说,我的人物类多余了,想要构建一个角色必须直接使用职业的类。
所以有人会提出这样的观点:我们可以放弃人物类,而是事先规划好所有的职业类,然后分别写出不同的类。
在少量的代码中或许能行通,但是在大工程中,也许某天甲方说这个方案临时修改,要再加一个职业,那么在海量的代码中修改所有的职业逻辑简直是一个灾难。所以这个观点行不通。
于是有人提出来又一个观点:不如设计一个公用的人物接口,当用户选择哪一个职业的时候,这个公用接口就会匹配到这个类,而且这样即便是新加职业或者去掉一个职业,都只需要把类删了就行,不需要动整个逻辑,因为逻辑是按照公用类写的。
于是虚函数就诞生了。
#include <iostream>
using namespace std;
class character{
public:
virtual void func(){
cout<<"我是人物"<<endl;
}
};
class swordsman : public character{
public:
void func() override{
cout<<"我是剑客"<<endl;
}
};
int main(){
character* a = new swordsman;
a->func();
}
执行结果
我是剑客
这是修改之后的代码,可以看到人物类可以像调用自己的函数一样调用派生类的成员函数,这个时候人物类就是一个公用的接口了。
核心点2:为什么父类的指针可以用来接住子类的动态空间
在上述例子中有一个匪夷所思的情况,那就是character* a = new swordsman;代码,在直觉中,类与类之间的差距巨大,而且不同的类数据也不一样等,这些杂糅在一起,就容易给初学者带来一种不明白,但是想问却又感觉自己不知道自己哪个地方不明白的情况。
实际上,在有c语言的基础时就能意识到一点——动态内存是不知道自己存了什么东西,它只知道自己占用了多少字节的空间。也就是说,本质上一个动态内存空间是允许任意类型的数据访问,但是c++比较严格,必须要显式声明数据类型的转换。所以这个也决定了这篇内存不管谁是开辟者,都不会在释放的时候出现内存泄露。因为空间记住的是内存长度而不是内存结构。
而在这个代码中,剑客类确实将所有的数据存入了这片内存,但是人物类接住了这个地址,人物类由于没有派生类的变量。所以在正常情况下只要派生类定义了新的变量,那么人物类是无法访问的。
但是重点来了:人物类事先定义了一组接口,它是一定能访问这些接口的,而虚函数派生类的初衷就是重写这个接口,围绕着这个接口进行二次开发。所以我的人物类完全不需要在意派生类定义了什么数据,它不在乎也不需要在乎,因为不管定义什么数据,都需要被子类重写的虚函数使用,然后父类调用子类,那么子类的数据就会被使用到。就像使用标准库中,你从来不需要关系printf 内部的局部变量是什么,因为它已经提供了一个接口函数是printf,至于派生类定义的成员变量,那就是派生类自己的事情了,不管它怎么用,只要保证实现的函数功能能跑就行。
虚析构函数
在使用了虚函数的实际开发中,可能会遇到一个情况,那就是基类中占用了资源,但是由于基类的指针本质上指向的是派生类的空间,在空间释放后,只会释放派生类而不是释放基类,那么就需要一种方式调用基类自己的析构函数。这就是虚析构函数。
标准格式:
virtual 析构函数 //基类
举一个例子:
#include <iostream>
using namespace std;
class character{
public:
virtual void func(){
cout<<"我是人物"<<endl;
p = new int;
}
virtual ~character(){
cout<<"释放人物类"<<endl;
delete p;
}
private:
int* p;
};
class swordsman : public character{
public:
void func() override{
cout<<"我是剑客"<<endl;
p = new int;
}
~swordsman(){
cout<<"释放剑客类"<<endl;
delete p;
}
private:
int *p;
};
int main(){
character* a = new swordsman;
a->func();
delete a;
}
执行结果:
我是剑客
释放剑客类
释放人物类
抽象类与纯虚函数
纯虚函数是一种特殊的虚函数,它在基类中声明但没有具体实现。纯虚函数的声明形式是在函数声明后添加 = 0。例如:
class Base {
public:
virtual void pureVirtualFunction() = 0; // 纯虚函数
};
抽象类是包含至少一个纯虚函数的类。抽象类不能实例化对象,只能作为基类使用。
不过要补充一点:纯虚函数就像是一种传染性极强的病毒,如果派生类继承了一个抽象类,但是没有完全重写所有纯虚函数,那么这个派生类会被转化为一个抽象类,从而也无法实例化。
举一个例子
#include <iostream>
using namespace std;
class A{
public:
virtual void func_1() = 0;
virtual void func_2() = 0;
};
class B: public A{
public:
void func_1() override{
cout<<"这是B的func1"<<endl;
}
};
class C:public B{
public:
void func_1() override{
cout<<"这是C的func1"<<endl;
}
void func_2() override{
cout<<"这是C的func2"<<endl;
}
};
int main(){
A *a = new C;
a->func_1();
}
执行结果
这是C的func1
在这个例子中,B类就被感染成了抽象类,它无法实例化,否则会报错。
补充
友元函数和友元类
在C++中,友元(Friend)是一种特殊的函数或类,它虽然定义在类的外部,但可以访问该类的所有成员,包括私有成员和保护成员。友元的声明需要使用关键字 friend。
友元的主要目的是提供一种机制,使得外部函数或类能够访问类的私有成员和保护成员。这在某些情况下非常有用,例如需要访问类的内部数据进行某些操作时。
尤其要注意的是:友元是单向权限,也就是我把你当朋友,你不一定把我当朋友。
友元函数
在C++中,友元函数(Friend Function)是一种特殊的函数,它虽然定义在类的外部,但可以访问类的所有成员,包括私有成员和保护成员。友元函数的声明需要使用关键字 friend。
友元函数是在类的定义中使用 friend 关键字声明的函数。它可以是全局函数,也可以是其他类的成员函数。其次友元函数可以访问类的所有成员,包括私有成员和保护成员。
示例:
#include <iostream>
using namespace std;
class Box {
private:
double width;
public:
Box(double w) : width(w) {}
// 声明友元函数
friend void printWidth(Box box);
};
// 友元函数定义
void printWidth(Box box) {
// 可以访问 Box 类的私有成员
cout << "Width of box: " << box.width << endl;
}
int main() {
Box box(10.0);
printWidth(box); // 输出:Width of box: 10
return 0;
}
友元函数也可以是另一个类的成员函数。比如:
class AnotherClass {
public:
void display(Box& box);
};
class Box {
private:
double width;
public:
Box(double w) : width(w) {}
// 声明 AnotherClass 的成员函数为友元
friend void AnotherClass::display(Box& box);
};
void AnotherClass::display(Box& box) {
cout << "Width of box: " << box.width << endl;
}
友元函数的优缺点:
- 优点:
- 提供了一种访问类私有成员的灵活方式。
- 可以实现某些需要访问类内部数据的功能,而不破坏类的封装性。
- 缺点:
- 破坏了类的封装性,因为友元函数可以访问类的私有成员。
- 可能导致代码的可维护性降低,因为友元函数的使用增加了类之间的耦合。
友元类
在C++中,友元类(Friend Class)是一种特殊的类,它可以访问另一个类的所有成员,包括私有成员和保护成员。友元类的声明需要使用关键字 friend。
当一个类被声明为另一个类的友元类时,这个友元类的所有成员函数都可以访问该类的私有成员和保护成员,友元类提供了一种机制,使得两个类之间可以共享私有数据,通常用于实现紧密耦合的类之间的协作。
举一个例子:
#include <iostream>
using namespace std;
class Box {
private:
double width;
public:
Box(double w) : width(w) {}
// 声明友元类
friend class BoxFriend;
};
class BoxFriend {
public:
void printWidth(Box& box) {
// 可以访问 Box 类的私有成员
cout << "Width of box: " << box.width << endl;
}
};
int main() {
Box box(10.0);
BoxFriend bf;
bf.printWidth(box); // 输出:Width of box: 10
return 0;
}
