文章目录
1 简单认识面向过程与面向对象
我个人之前学的是C语言,现在学的是C++,C语言和C++最显著的区别之一就是,C语言是一门面向过程的语言,C++是一门面向对象的语言。
什么是面向过程,什么是面向对象,我个人认为理解和区分是很重要的,因为这代表着两套程序设计思想,调整认知有助于向前学习。
1.1 面向过程
C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
以 “ 人洗衣服 ” 这件事为例:
① 输入阶段:准备好脏衣服、盆、洗衣粉、水、人等。
② 处理阶段:将洗衣服的过程划分为一个个的步骤,然后实现算法来逐步完成。
③ 输出阶段:得到干净的衣服。
1.2 面向对象
C++是基于面向对象的,关注的是对象,世界上的任何事物都能够认为是一个对象,当完成一件事中有多个对象参与时,将这些对象拆分出来,靠对象之间的交互完成。
同样以 “ 人洗衣服 ” 这件事为例(不过这次用的是洗衣机😂):
2 类的引入:struct -> class
除了 “ 面向对象 ” 之外,还有一个词经常与对象搭配,那就是 “ 类与对象 ” 中的 类(class),C++中的类又是什么,它是新增的语法,还是C语言语法的演变?
答案是后者,C++中的类并非新增的语法,而是C语言的语法演变和扩
展。C++是在C语言的基础上发展而来的,其中类的概念是通过对C语言的结构体进行扩展和增强而来的。
C语言的结构体中只能声明变量,函数不能在结构体中实现,换言之,数据和方法是分离的。
C++在兼容C语言的 struct 的同时将 struct 升级成了 类(class),新增了能够在struct内部定义函数的语法。
不仅如此,还有其他方便的新语法:
- 类名即类型,不再需要
typedef struct Stack Stack;
。 - 方法的定义在struct内部,能够直接访问成员,不再需要手动传地址。
- 能够直接使用成员运算符
.
来调用方法。 - ……
3 类的定义
在C++中,定义类(class)的关键字除了 struct 外还有 class,相较于 struct,C++更喜欢使用 calss 来定义类。
3.1 class 定义类的语法
class className
{
// 类体:由成员方法和成员变量组成
}; // 一定要注意后面的分号
注释:
- class 为定义类的关键字,ClassName 为类的名字,{} 中为类的主体,注意类定义结束时后面分号不能省略。
- 类体中所有内容称为类的成员:类中的变量部分称为类的属性或成员变量; 类中的函数函数称为类的方法或者成员函数。
3.2 成员变量的命名建议
来看这个例子:
class Date
{
public:
void Init(int year)
{
year = year;
}
private:
int year;
};
由于局部优先原则,Init
方法里=
左右都是形参,相当于自己给自己赋值,完全达不到初始化的作用。这时候为了避免冲突,就要被迫修改方法的形参,但是 C++ 一般习惯性的给成员变量名前加上 _
的做法,这是因为在C++ 中,名字前面带 _
大多表示内部的意思。
class Date
{
public:
void Init(int year)
{
_year = year;
}
private:
int _year;
};
当然,这不是硬性要求,而仅仅只是一个建议而已,也可以有其他的方法,但是为了解决命名冲突,形参与成员变量二者总得改一个。
4 类的访问限定符
class 关键字 和 struct 关键字都可以定义类,但是它们定义出来的类是有一定差别的,这个就涉及到类的访问限定符了。
C++规定,类的访问限定符有3个:
【访问限定符的说明】
- ① 被 public 修饰的成员(变量 + 方法)能够在类外被直接访问。
②被 private、protected 修饰的成员(变量 + 方法)无法在类外被直接访问。( private 和 protected 在学习继承之前,可以认为没有差别) - 访问限定符的作用范围:① 从该访问限定符出现的位置开始直到下一个访问限定符出现时为止;② 如果后面没有访问限定符,作用域就到
}
即类结束。
4.1 class 和 struct 的区别
下面这份代码中,仅仅只是修改了一个关键字,class 定义的类中的变量和方法就都无法访问了。
struct 和 class 的区别在于,struct 的默认访问限定符是 public,而 class 的访问限定符是 private。
4.2 C++更喜欢用class定义类的原因
以这一段代码为例,其中 Stack 是 struct 定义的类
int main()
{
Stack st;
st.Push(1);
st.Push(2);
st.Push(3);
st.Push(4);
// if(!st.Empty())
// cout << st.Top() << endl;
if (st._size != 0)
cout << st.Top() << endl;
return 0;
}
正是由于默认访问限定符是 public,在类外既可以访问成员方法,又可以访问成员变量,但是自由会带来一定问题,在这个例子里就有两方面的问题:
第一:代码不够规范,可读性稍差。
第二:这个问题就比较致命,判断栈是不是空存在两种方案,一是st._size == 0
,二是st._size == -1
,在不了解具体实现的情况下是无法判断这个 if 的含义。
这时候,代码质量高低只能依靠程序员的素养高低。
而使用class定义的类就会强制性的将成员都认为是私有的,然后再由程序员去选择性公开哪些成员,这就有助于保护内部数据的安全,同时也形成良好的代码规范。
5 类的作用域
类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用 ::
作用域操作符指明成员属于哪个类域。
同时,这也衍生出了类的两种定义方式。
5.1 声明定义合并
一般我们都会将声明放在 .h 文件中,而声明定义合并指的是,声明和定义全部放在类体中,换言之,声明与定义都在一个文件中。(需注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理)
5.2 声明定义分离
声明和定义分离指的是,类声明放在.h文件中,成员函数定义放在.cpp文件中,注意:。cpp文件中的成员函数名前需要加类名::
,一般情况下,为了方便管理更期望采用分离的方式来实现一个类。
6 类的实例化
6.1 声明定义的区分
class A
{
public:
void Print();
private:
int a;
int b;
char c;
};
现有一个 A 类,问 Print
、a
、b
、c
这四个成员中,哪些是定义哪些是声明?
我们很容易区分出来 Print
是函数的声明,但是对于 3 个成员变量,乍一看就有点犯难了,但其实它们3个是声明,变量的声明和定义的区分在于有没有开空间,没开辟实际的空间就是声明。那什么时候才开辟空间呢?类实例化对象的时候。
6.2 实例化的理解
- 什么是类?
C语言的结构体是一种用户自定义的数据类型,C++的类是从C语言的结构体演变过来的,很显然,类说白了也是一种用户自定的数据类型。 - 什么是对象?
C语言的结构体类型创建(定义)的变量,称之为 “ 结构体变量 ”,但是由于类的提出,C++这边给了它一个新的术语——对象。 - 什么是实例化?
用类类型创建对象的过程,就称为类的实例化,实例化出的对象,占用实际的物理空间,成员变量就是在这个时候被定义出来的。
再做个比方。
类实例化出对象就像现实中使用建筑设计图建造出房子,类就像是设计图,只设计出需要什么东西,设计图是不能住人的,同样类也只是一个设计,实例化出的对象才能实际存储数据,占用物理空间
7 类对象模型
class persion
{
public:
void SetPersionInfo()
{
// ...
}
void PrintPersionInfo()
{
// ...
}
private:
char* _name;
char* _gender;
int _age;
};
可以看到,类中既可以有成员变量,又可以有成员函数,这显然很正常,但是问题来了,那么一个类实例化的对象中包含了什么?如何计算一个类的大小,指 sizeof(对象)?换言之,类对象的模型到底是如何设计的?
7.1 方案1:对象中包含类的各个成员
这个方案指的是,每创建一个对象都会将类中的代码保存一份,这种方式是最简单直接的。
但是这个方案有很大的缺陷,对象之间除了成员变量的内容是不同的以外其余的代码都是相同的,相同的代码却被保存了多份,这是一种极其浪费空间的设计,所以肯定不是这种。
7.2 方案2:代码只保存一份,在对象中保存存放代码的地址
方案2是针对方案1的优化,既然函数的定义都是相同的,那么就将相同的函数定义存储在公共的函数表中,函数表中包含了所有成员函数的地址,而对象中额外存储一个指向函数表的指针。
当调用成员函数时,首先通过对象的指针找到函数表,然后再根据函数名在函数表中查找对应的函数地址,最后跳转到函数地址处执行函数。
7.3 方案3:只保存成员变量,成员函数存放在公共的代码段
而方案3就是简单直接的延续C语言时 struct 的行为方式,对象内部只存储成员,函数定义都放到公共的代码段,调用函数的时候根据经过函数名修饰规则处理后的函数名去代码段查找。
【方案2 vs 方案3】
方案2的确可以实现共享函数代码的目的,但相比方案三显得有点多次一举,它增加了额外的指针开销和间接访问成本。因为每次调用成员函数都需要通过指针找到函数表,再在函数表中查找对应的函数地址,增加了额外的内存访问和执行开销。
此外,由于函数表是公共的,可能会导致缓存的失效,降低了执行效率。
7.4 类对象模型的验证
【回顾结构体内存对齐规则】
- 第一个成员在与结构体偏移量为0的地址处。
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
注意:对齐数 = 编译器默的一个对齐数 与 该成员大小的较小值。
VS中默认的对齐数为8。 - 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
- 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
【主流平台测试类对象使用方案】
经测试发现,两个平台的测试结果都符合方案3的预期,很显然使用的都是方案3。
因此结论为:一个类的大小,实际就是该类中”成员变量”之和,当然,要注意内存对齐。
除此之外还有一个特殊的存在,就是无成员变量的类。
对于这种类,编译器会给 1 个字节来唯一标识这个类的对象。
8 this 指针
8.1 什么是 this 指针
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(2022,1,11);
d2.Init(2022, 1, 12);
d1.Print();
d2.Print();
return 0;
}
对于上述类,有这样的一个问题:
Date类中有 Init 与 Print 两个成员函数,函数体中没有关于不同对象的区分,那当d1调用 Print
函
数时,该函数是如何知道输出d1而不是d2呢?
C++中通过引入this
指针解决该问题,即:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
8.2 this 指针的特性及注意问题
- this 是一个只能在 “ 成员函数 ” 的内部使用关键字。
- this 指针的类型是
类的类型 * const
,即成员函数中,不能给 this 指针赋值。 - this 指针是 “ 成员函数 ” 第一个隐含的指针形参,当对象调用成员函数时,一般情况由编译器通过 ecx 寄存器(指VS系列编译器)将对象地址作为实参传递给 this 形参。
- this 指针不能显式的传参和接收,但可以显式的使用。
- 因为 this 指针是形参,所以它是的空间是开辟在栈上的。
- 大多数情况下 this 指针不为空,但不排除有 this 是 nullptr 的场景,以下是两个典型案例:
解析:
- 上面提到过,对象不会存储成员方法的地址,所以,main 函数中的
p->Print()
的作用仅仅只是为了告诉编译器PrintA
同理。- 首先,空指针解引用报的是运行时错误,无论如何都不会选 编译报错;其次,
PrintA
方法中对成员_a
进行访问,即对 this 进行解引用,所以引发运行崩溃。
9 类的默认成员函数
如果一个类中什么成员都没有,简称为空类。空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。
默认成员函数指的是,用户没有显式实现,编译器会生成的成员函数称为默认成员函数。
9.1 构造函数
构造函数的名字虽然叫 “ 构造 ”,但是它的工作并不是开辟空间,而是初始化对象内部成员。
9.1.1 构造函数的特征
- 函数名与类名相同。
- 无返回值(不是返回值类型为
void
,而是直接就不写)。 - 对象实例化时编译器自动调用对应的构造函数。
- 构造函数可以重载。
class Date
{
public:
// 1.无参构造函数
Date()
{}
// 2.带参构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
void TestDate()
{
Date d1; // 调用无参构造函数
Date d2(2015, 1, 1); // 调用带参的构造函数
// 使用注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明
// 以下代码的函数:声明了d3函数,该函数无参,返回一个日期类型的对象
// warning C4930: “Date d3(void)”: 未调用原型函数(是否是有意用变量定义的?)
Date d3();
}
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
class Date
{
public:
/*
// 如果用户显式定义了构造函数,编译器将不再生成
Date(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类中构造函数注释后,代码可以通过编译,因为编译器生成了一个无参的默认构造函数
// 将Date类中构造函数放开,代码编译失败,因为一旦显式定义任何构造函数,编译器将不再生成
// 无参构造函数,放开后报错:error C2512: “Date”: 没有合适的默认构造函数可用
Date d1;// 试图调用无参默认构造函数
return 0;
}
- 无参的构造函数和全缺省的构造函数都称为 “ 默认构造函数”,并且默认构造函数只能有一个。
class Date
{
public:
// 用户显式定义的无参构造函数
Date()
{
_year = 1900;
_month = 1;
_day = 1;
}
// 用户定义的全缺省构造函数
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
// 以下测试函数能通过编译吗?
void Test()
{
Date d1;
}
答案是不能,虽然两个构造函数构成重载,没有语法错误,但是调用函数时存在歧义,编译器不知道该调用无参构造函数还是全缺省构造函数。
总结一下,能够不传参的都叫默认构造函数,符合条件的有三个:
- 我们不写编译器自己默认生成的构造函数。
- 我们自己显式定义的无参构造函数。
- 全缺省的构造函数。
- 我们没写编译器默认生成的构造函数对内置类型成员变量和自定义类型成员变量分别存在下面两种行为:
- 内置类型成员变量(如 int、float、任意指针等语言原生提供的类型):
默认构造函数不会对成员执行任何初始化操作。如果类中有内置类型成员,但没有显式定义构造函数,那么该类的对象的内置类型成员将保持未初始化的状态,即随机值。 - 自定义类型成员(如 class / struct / union 等用户自定义的类型):
默认构造函数会去调用成员变量自身的默认构造函数。如果成员自身没有默认构造函数,编译器无法生成,会报编译报错,解决问题的方法就是初始化列表。
- 内置类型成员变量(如 int、float、任意指针等语言原生提供的类型):
但是构造函数的这条语法在设计上有一定缺陷:
-
对象不完全初始化: 在早期版本的C++中,如果你定义了一个类,其中包含内置类型的成员变量,但没有显式初始化它们,那么在创建该类的对象时,这些成员变量将保持未初始化的状态,即它们的值将是未定义的。这可能导致对象的行为不确定,因为它们依赖于未初始化的成员变量。这种情况会增加代码的难以理解和调试,也不利于程序的健壮性。
-
与用户定义类型的一致性: 在C++中,你可以为用户定义类型(例如类)的成员变量提供默认初始化值,但对于内置类型的成员变量,却无法在类的声明中为其提供默认值。这种不一致性使得代码更难以理解和维护,因为它需要程序员记住哪些变量可以提供默认值,哪些变量不行。
因此,C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
class Date
{
private:
// 基本类型(内置类型)
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
9.1.2 函数体赋值 VS 初始化列表
前面已经说明构造函数是用来初始化对象的成员变量的,可自定义类型对象无默认构造函数可调用这件事怎么处理先不说,构造函数不是已经使用形参为成员初始化了吗,可为什么编译器错误输出中显示成员_ref
、_n
都没有初始化,这是出bug了?
一般来说,主流编译器是不会出这么大的bug的,肯定是哪里的知识有缺漏。
首先,构造函数的作用是在创建对象时初始化对象的成员;其次, 我们之前一直以为函数体{}
内的赋操作就是初始化,但是从测试结果来看,显然不是;所以,问题所在就是,创建对象时,对象的成员到底在哪里被初始化的?
答案就是初始化列表!
初始化列表:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。
class Date
{
public:
Date(int year, int month, int day)
: _year(year)
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};
【注意】
- 每个成员变量在初始化列表中只能出现一次(初始化只能初始化一次)
- 类中包含以下成员,必须放在初始化列表位置进行初始化:
- 引用成员变量
- const成员变量
- 自定义类型成员(且该类没有默认构造函数时)
class A
{
public:
A(int a)
:_a(a)
{}
private:
int _a;
};
class B
{
public:
B(int a, int ref)
:_aobj(a) // 调用A类的构造函数
, _ref(ref) // 用ref作为初始值初始化成员_ref
, _n(10) // 用 10 作为初始值初始化_n
{}
private:
A _aobj; // 没有默认构造函数
int& _ref; // 引用
const int _n; // const
};
- 尽量使用初始化列表初始化,因为不管你是否使用初始化列表,对于自定义类型成员变量,一定会先使用初始化列表初始化。
class Time
{
public:
Time(int hour = 0)
:_hour(hour)
{
cout << "Time()" << endl;
}
private:
int _hour;
};
class Date
{
public:
Date(int day)
{}
private:
int _day;
Time _t;
};
int main()
{
Date d(1);
return 0;
}
- 成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关,建议声明顺序和初始化列表顺序保持一致。
class A
{
public:
A(int a)
:_a1(a)
,_a2(_a1)
{}
void Print()
{
cout<<_a1<<" "<<_a2<<endl;
}
private:
int _a2;
int _a1;
};
int main()
{
A aa(1);
aa.Print();
}
这段代码的输出结果是1 随机值
- 不显式使用初始化列表是编译器初始化对象成员的逻辑是,内置类型成员不做处理,自定义类型成员回去调用它的默认构造函数,一旦没有默认构造函数就必须得显式使用初始化列表来初始化自定义类型的成员。
- C++11增加了类成员变量声明时可给缺省值的语法补丁,我们自己给的缺省值就是交由初始化列表来使用的。
9.1.3 explicit关键字
9.1.3.1 隐式类型转换
int main()
{
int i = 0;
double d = i;
const double& ref = i;
// double& ref = i; error
return 0;
}
这段代码的赋值运算符左右两边的对象的类型是不一致的,这是因为相近类型之间会进行隐式类型转换,类型转换过程中会产生临时变量,d
、ref
并不是用i
来初始化,而是用产生的临时变量来初始化,而临时变量具有常属性,这就是double& ref = i;
无法编译通过的原因。
9.1.3.2 单参数构造函数隐式类型转换
构造函数不仅可以构造与初始化对象,对于单个参数或者除第一个参数无默认值其余均有默认值或者全缺省的构造函数,还具有类型转换的作用。
class Date
{
public:
// 1. 单参构造函数
Date(int year)
:_year(year)
{
cout << "1. 单参构造函数" << endl;
}
/*
// 2. 除第一个参数无默认值其余均有默认值
Date(int year, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{
cout << "2. 除第一个参数无默认值其余均有默认值" << endl;
}
*/
/*
// 3. 全缺省
Date(int year = 1970, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{
cout << "3. 全缺省" << endl;
}
*/
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2022);
// 用一个整形变量给日期类型对象赋值
// 实际编译器背后会用2023构造一个无名对象,最后用无名对象给d1对象进行赋值
d1 = 2023;
return 0;
}
但是这三个之中只能任意存在一个,否则会存在调用歧义。
9.1.3.3 explicit关键字禁止隐式类型转换
使用 explicit 关键字修饰对应的变量和函数就可以禁止隐式类型转换。
class Date
{
public:
// 1. 单参构造函数,没有使用explicit修饰,具有类型转换作用
// explicit修饰构造函数,禁止类型转换---explicit去掉之后,代码可以通过编译
explicit Date(int year)
:_year(year)
{}
/*
// 2. 虽然有多个参数,但是创建对象时后两个参数可以不传递,
// 没有使用explicit修饰,具有类型转换作用
// explicit修饰构造函数,禁止类型转换
explicit Date(int year, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{}
*/
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
private:
int _year;
int _month;
int _day;
};
void Test()
{
Date d1(2022);
// 用一个整形变量给日期类型对象赋值
// 实际编译器背后会用2023构造一个无名对象,最后用无名对象给d1对象进行赋值
d1 = 2023;
// 将1屏蔽掉,2放开时则编译失败,因为explicit修饰构造函数,
// 禁止了单参构造函数类型转换的作用
}
9.1.3.4 单参数隐式类型转换的应用场景
#include <iostream>
#include <vector>
using namespace std;
class A
{
public:
A(int a)
:_a(a)
{}
private:
int _a;
};
int main()
{
vector<A> v;
// 有名对象
A aa(1);
v.push_back(aa);
// 匿名对象
v.push_back(A(2));
// 单参数构造函数隐式类型转换
// 显然这种是最便捷的
v.push_back(3);
return 0;
}
9.2 析构函数
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
9.2.1 析构函数的特性
- 析构函数名是在类名前加上字符
~
。 - 无参数无返回值类型。
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
注意:析构函数不能重载 - 对象生命周期结束时,C++编译系统系统自动调用析构函数。
【析构函数代码例子1:Stack 类析构函数如何实现、何时调用、做了什么】
- 关于编译器自动生成的析构函数,是否会完成一些事情呢?
下面的程序我们会看到,编译器生成的默认析构函数,对内置类型成员不做处理,对自定类型成员调用它的析构函数。
【析构函数代码例子2:验证编译器自动生成的析构函数的行为】
- 哪些类该实现构造函数,哪些类使用默认生成的构造函数就行了呢?
如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如
Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。
9.3 拷贝构造函数
拷贝构造函数是一个特殊的构造函数,用于创建一个对象,其内容与另一个同类对象相同。它接受一个同类对象的引用作为参数,并创建一个新的对象,内容与传递的对象相同。
9.3.1 拷贝构造的必要性和使用场景
拷贝构造函数的存在是为了在某些情况下创建对象的副本,确保对原始对象进行操作时不影响到其他对象,主要场景包括:
-
对象传值传参:通过值传递对象给函数时,确保在函数内部操作的是对象的副本而不是原始对象。
-
函数返回值是对象:在函数返回对象时,确保返回的是对象的副本而不是原始对象。
-
旧对象初始化新对象:当对象初始化为另一个对象时,确保生成的是新的副本而不是简单的指向同一内存地址的引用。
拷贝构造函数的提出是为了解决对象复制时可能遇到的深浅拷贝问题,确保对象的复制是完整的,避免因共享资源的问题导致的错误行为,以第一个场景为例,里面用到的类有两个 Date 和 Stack 。
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1);
private:
int _year = 1970;
int _month = 1;
int _day = 1;
};
class Stack
{
public:
Stack(size_t capacity = 3);
private:
int* _array = nullptr;
int _capacity = 0;
int _size = 0;
};
浅拷贝指的就是字节序的值拷贝,深拷贝指的是指在复制对象时,不仅复制对象本身的值,还复制对象所包含的所有动态分配的资源,使得原始对象和新对象之间完全独立,互不影响。拷贝构造函数解决的就是对象之间的深拷贝问题
9.3.2 实现拷贝构造要注意的几个问题
- 拷贝构造函数是构造函数的一个重载形式。
- 拷贝构造函数规定:
①参数只有一个
②const 类类型对象的引用
③如需深拷贝,要确保新对下个拥有独立的资源。
// Date 类的拷贝构造函数的实现
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
// Stack 类的拷贝构造函数的实现
Stack(const Stack& st)
{
_capacity = st._capacity;
_size = st._size;
// 为新对像分配独立资源
_array = (int*)malloc(sizeof(int) * _capacity);
for (int i = 0; i < _size; ++i)
{
_array[i] = st._array[i];
}
}
注意问题1:形参不用引用导致死递归
如果形参不用引用,调用拷贝构造需要先传值,先传值需要调用新的拷贝构造,调用拷贝构造需要先传值,先传值需要再次调用新的拷贝构造……这样就形成了一种无限递归的死循环,直至栈溢出。
注意问题2:形参不用 const 实参被修改
以 Date 类的拷贝构造函数为例:
Date(const Date& d)
{
d._year = _year;
d._month = _month;
d._day = _day;
}
然后我们就可以看到一件很扯淡的事情发生了,本来是想用 对象d 来初始化新对象的,但是对象 d 反而把自己赔了进去。
9.3.3 探究编译器生成的拷贝构造函数的行为
以下面这段代码为例:
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
Time(const Time& t)
{
_hour = t._hour;
_minute = t._minute;
_second = t._second;
cout << "Time::Time(const Time&)" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型)
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d1;
// 用已经存在的d1拷贝构造d2,此处会调用Date类的拷贝构造函数
// 但Date类并没有显式定义拷贝构造函数,则编译器会给Date类生成一个默认的拷贝构造函数
Date d2(d1);
return 0;
}
总结:如果未显式定义拷贝构造函数,编译器会生成默认的拷贝构造函数,对于内置类型成员,简单地进行按字节序的值拷贝;对于自定义类型成员,则会调用其对应的拷贝构造函数。
9.3.4 什么样的类需要实现拷贝构造函数?
类中如果没有涉及资源申请时(比如 Date),拷贝构造函数是否写都可以;一旦涉及到资源申请时(比如 Stack),则拷贝构造函数是一定要写的,否则就是浅拷贝。
9.4 赋值运算符重载
9.4.1 运算符重载
了解赋值运算符重载之前先要了解什么是运算符重载。
C++提出运算符重载的主要原因之一是为了提供更高的灵活性和可读性。通过允许用户自定义类型的对象使用与内置类型相同的运算符,可以使代码更加直观和易于理解。
运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号(如 operator+、operator- 等)。
函数原型:返回值类型 operator运算符(参数列表)
相关注意点有 4 个:
-
不能通过连接其他符号来创建新的操作符:比如operator@。
-
重载操作符必须有一个类类型参数,理由是规定用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不 能改变其含义。
-
作为类成员函数重载时,其形参看起来比操作数数目少 1 ,因为成员函数的第一个参数为隐藏的 this。
-
无法重载的运算符有 5 个:
.*
、::
、sizeof
、?:
、.
。
【代码例子1:operator== 重载为全局函数】
运算符重载成全局的就需要成员变量是公有的,那么问题来了,封装性如何保证?
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//private:
int _year;
int _month;
int _day;
};
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
void Test()
{
Date d1(2018, 9, 26);
Date d2(2018, 9, 27);
cout << (d1 == d2) << endl;
}
【代码例子2:operator== 重载为类的成员函数】
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// bool operator==(Date* this, const Date& d2)
// 这里需要注意的是,左操作数是this,指向调用函数的对象
bool operator==(const Date & d2)
{
return _year == d2._year
&& _month == d2._month
&& _day == d2._day;
}
private:
int _year;
int _month;
int _day;
};
void Test()
{
Date d1(2018, 9, 26);
Date d2(2018, 9, 27);
cout << (d1 == d2) << endl;
// 编译器视角:
// cout << (d1.operator==(d2)) << endl;
// cout << (d1.operator==(&d1, d2)) << endl;
}
9.4.2 赋值运算符重载
赋值运算符重载的格式
- 参数类型:
const 数据类型&
(如const Date&
),传递引用可以提高传参效率。 - 返回值类型:
数据类型&
(如Date&
),返回引用可以提高返回的效率。 - 检测是否自己给自己赋值。
- return *this:有返回值目的是为了支持连续赋值(如 d1 = d2 = d3)。
【代码例子3:显式实现 Date 类的赋值运算符重载】
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
private:
int _year;
int _month;
int _day;
};
赋值运算符只能重载成类的成员函数不能重载成全局函数
【代码例子4:operator = 重载为全局函数编译报错】
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// private:
int _year;
int _month;
int _day;
};
// 赋值运算符重载成全局函数,注意重载成全局函数时没有this指针了,需要给两个参数
Date& operator=(Date& left, const Date& right)
{
if (&left != &right)
{
left._year = right._year;
left._month = right._month;
left._day = right._day;
}
return left;
}
// 但是编译却失败了:
// error C2801: “operator =”必须是非静态成员
经过资料查证,发现原因如下:
赋值运算符是特殊规定的函数之一,如果类内不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
探究默认赋值运算符重载的行为
它的行为如下:内置类型成员变量是直接赋值的;自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。
【代码例子5:验证默认赋值运算符重载的行为】
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
Time& operator=(const Time& t)
{
cout << "Time& operator=(const Time& t)" << endl;
if (this != &t)
{
_hour = t._hour;
_minute = t._minute;
_second = t._second;
}
return *this;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型)
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
// 无显式实现的构造函数,编译器自己生成默认构造函数
Date d1;
Date d2;
// 无显式实现的赋值重载函数,编译器自己生成默认赋值重载函数
d1 = d2;
return 0;
}
运行结果就是最好的证明:
总结:如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要实现。
9.5 对象 & 及 const 对象 & 重载
9.5.1 const 对象调用非 const 成员函数
以这一份代码为例,代码中创建普通对象d1并调用Date类中Print方法,创建const对象d2并调用Date类中的Print方法。
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << "Print()" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl << endl;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
int main()
{
Date d1(2022, 1, 13);
d1.Print();
const Date d2(2022, 1, 13);
d2.Print();
return 0;
}
但是代码编译之后,编译器报错了,报错的代码是d2.Print();
这一句。
所以接下来分析一下,为什么会报错。
通过分析可以看到,对普通对象取地址和对 const 对象取地址得到的指针的类型是不一致,this 是可读可写的,&d2 是只读的,当const Date*
类型的指针传给Date*const
类型的 this 时,会导致权限放大问题,这是不被允许的。
所以解决方法是同一个再重载一个 const 版本的 Print 方法,但是该怎么用 const 关键字修饰 this 指针?
在C++语法规定,当你希望声明一个成员函数,该函数不修改对象的成员变量时,你可以在成员函数的参数列表后面加上 const 关键字来修饰 this 指针。
class MyClass {
public:
void myFunction() const {
// 以下行将导致编译错误
// this->memberVariable = newValue;
// 但是可以访问对象的成员变量,因为它们被视为常量
int value = this->memberVariable;
}
private:
int memberVariable;
};
总结成员函数加不加const修饰的原则:
- 能定义成const的成员函数都应该定义成 const,这样 cosnt 对象(权限平移)和非 const 对象(权限缩小)都能调用。
- 要修改成员变量的函数不加 const 修饰。
9.5.2 取地址及const取地址操作符重载
前面讲完了四个类的特殊的成员函数,这里剩下两个:
// T 指代对象类型
T* operator&();
const T* operator&() const;
这两个成员函数提出的目的主要是为了逻辑自洽,因为语法规定了,自定义类型使用运算符就要对运算符进行重载。
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!
10 类的 static 成员
概念
声明为 static 的类成员称为类的静态成员,用 static 修饰的成员变量,称之为静态成员变量;用 static 修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类内进行声明,类外进行初始化。
特性
- 静态成员为该类的所有对象所共享,不属于某个具体的对象,存放在静态区。
- 静态成员变量必须在类外定义,定义时不添加 static 关键字,类中只是声明。
- 类静态成员即可用
类名::静态成员
或者对象.静态成员
来访问。 - 静态成员函数没有隐藏的 this 指针,不能访问任何非静态成员(变量 + 方法)。
- 静态成员也是类的成员,受 public、protected、private 访问限定符的限制。
应用
【面试题:实现一个类,计算程序中创建出了多少个类对象。】
方法一:count是全局变量,构造对象和拷贝构造对象时,++count,可以解决但是容易被干扰。
方法二:count是成员变量,++count只会++对象自身的count成员,无法统计,无法解决。
方法三:count是静态成员变量,GetACount
是非静态成员函数时,为了获取count还得创建对象,也会产生干扰,可以解决但是不好。
方法四:count是静态成员变量,GetACount
是静态成员函数时,代码如下:
class A
{
public:
A() { ++_scount; }
A(const A& t) { ++_scount; }
~A() { --_scount; }
static int GetACount() { return _scount; }
private:
static int _scount;
};
int A::_scount = 0;
int main()
{
cout << A::GetACount() << endl;
A a1, a2;
A a3(a1);
cout << A::GetACount() << endl;
return 0;
}
11 友元
友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
友元分为:友元函数和友元类。
11.1 友元函数
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << "Print()" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl << endl;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
int main()
{
Date d1(2022, 1, 13);
d1.Print();
return 0;
}
对于 Date 类,想要输出对象的信息,我们总是要去调用 Print 方法,这固然可以但是不够便捷,我们期待能够直接使用流提取运算符<<
直接输出对象的信息,所以就要进行运算符重载。
问题:现在尝试去重载 operator<< ,然后发现没办法将 operator<< 重载成成员函数。因为 cout 的输出流对象和隐含的 this 指针在抢占第一个参数的位置。 this指针默认是第一个参数也就是左操作数了。但是实际使用中cout需要是第一个形参对象,才能正常使用。所以要将operator<<重载成全局函数,但又会导致类外没办法访问成员。
class Date
{
public:
Date(int year, int month, int day)
: _year(year)
, _month(month)
, _day(day)
{}
// d1 << cout; -> d1.operator<<(&d1, cout); 不符合常规调用
// 因为成员函数第一个参数一定是隐藏的this,所以d1必须放在<<的左侧
ostream& operator<<(ostream& _cout)
{
_cout << _year << "-" << _month << "-" << _day << endl;
return _cout;
}
private:
int _year;
int _month;
int _day;
};
C++规定:友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部声明,声明时需要加 friend 关键字。
class Date
{
// 类内添加友元声明
friend ostream& operator<<(ostream& _cout, const Date& d);
friend istream& operator>>(istream& _cin, Date& d);
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};
// 类外定义
ostream& operator<<(ostream& _cout, const Date& d)
{
_cout << d._year << "-" << d._month << "-" << d._day;
return _cout;
}
istream& operator>>(istream& _cin, Date& d)
{
_cin >> d._year;
_cin >> d._month;
_cin >> d._day;
return _cin;
}
int main()
{
Date d;
cin >> d;
cout << d << endl;
return 0;
}
【说明】
- 友元函数可访问类的私有和保护成员,但不是类的成员函数。
- 友元函数不能用const修饰。
- 友元函数可以在类定义的任何地方声明,不受类访问限定符限制。
- 一个函数可以是多个类的友元函数。
- 友元函数的调用与普通函数的调用原理相同。
11.2 友元类
友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员。
-
友元关系是单向的,不具有交换性。
比如上述Time类和Date类,在Time类中声明Date类为其友元类,那么可以在Date类中直接
访问Time类的私有成员变量,但想在Time类中访问Date类中私有的成员变量则不行。 -
友元关系不能传递
如果C是B的友元, B是A的友元,则不能说明C时A的友元。
-
友元关系不能继承。
class Time
{
// 声明日期类为时间类的友元类,则在日期类中就直接访问Time类中的私有成员变量
friend class Date;
public:
Time(int hour = 0, int minute = 0, int second = 0)
: _hour(hour)
, _minute(minute)
, _second(second)
{}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{}
void SetTimeOfDate(int hour, int minute, int second)
{
// 直接访问时间类私有的成员变量
_t._hour = hour;
_t._minute = minute;
_t._second = second;
}
private:
int _year;
int _month;
int _day;
Time _t;
};
12 内部类
12.1 概念
概念:如果一个类定义在另一个类的内部,这个内部类就叫做内部类。
内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有任何优越的访问权限。
但是,内部类天生就是外部类的友元类,参见友元类的定义,内部类可以通过外部类的对象参数来访问外部类中的所有成员。但是外部类不是内部类的友元。
内部类有以下特性:
特性:
- 内部类可以定义在外部类的public、protected、private都是可以的,换言之,内部类受访问限定符修饰。
- 注意内部类可以直接访问外部类中的static成员,不需要外部类的对象/类名。
sizeof(外部类)=外部类
,和内部类没有任何关系,内外两个类是独立的。
class A
{
private:
static int k;
int h;
public:
class B // B天生就是A的友元
{
public:
void foo(const A& a)
{
cout << k << endl;//OK
cout << a.h << endl;//OK
}
};
};
int A::k = 1;
int main()
{
A::B b;
b.foo(A());
return 0;
}
13 构造时一些编译器的优化(拓展)
在传参和传返回值的过程中,一般编译器会做一些优化,减少对象的拷贝,这个在一些场景下还是非常有用的。这里的优化不是所有的编译器都有,但是一般新版本的编译器都有。
- 同一个表达式中的 构造函数 + 构造函数 会被优化成 构造函数。
- 同一个表达式中的 构造函数 + 拷贝构造函数 会被优化成 构造函数。
- 同一个表达式中的 拷贝构造函数+拷贝构造函数 会被优化成 拷贝构造函数。
class A
{
public:
A(int a = 0)
:_a(a)
{
cout << "A(int a)" << endl;
}
A(const A& aa)
:_a(aa._a)
{
cout << "A(const A& aa)" << endl;
}
A& operator=(const A& aa)
{
cout << "A& operator=(const A& aa)" << endl;
if (this != &aa)
{
_a = aa._a;
}
return *this;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
void f1(A aa)
{}
A f2()
{
A aa;
return aa;
}
int main()
{
// 调用构造
A aa1;
// 传值传参调用拷贝构造
f1(aa1);
cout << endl;
// 传值返回调用拷贝构造
f2();
cout << endl;
// 隐式类型,连续构造+拷贝构造->优化为直接构造
f1(1);
// 一个表达式中,连续构造+拷贝构造->优化为一个构造
f1(A(2));
cout << endl;
// 一个表达式中,连续拷贝构造+拷贝构造->优化一个拷贝构造
A aa2 = f2();
cout << endl;
// 一个表达式中,连续拷贝构造+赋值重载->无法优化
aa1 = f2();
cout << endl;
return 0;
}
输出:
A(const A& aa)
~A()
A(int a)
A(const A& aa)
~A()
~A()
A(int a)
~A()
A(int a)
~A()
A(int a)
A(const A& aa)
~A()
A(int a)
A(const A& aa)
~A()
A& operator=(const A& aa)
~A()
~A()
~A()