目录
1. 再谈构造函数
1.1. 初始化列表:
初始化列表是一个以冒号开始,接着是一个以逗号分隔的数据成员列表,每个成员变量后面跟一个放在括号中的初始值或表达式;
我们以前学习构造函数的时候,都是在构造函数体内完成成员属性的初始化,那么一个类中所有的成员属性都可以在构造函数体内进行初始化吗?
1.1.1. const成员属性
const 成员属性是否可以在构造函数体内进行初始化?
测试 demo 如下:
namespace Xq
{
template<class T>
class my_class
{
public:
my_class(const T val = T())
{
_memberval = val;
}
private:
const T _memberval; //const成员属性
};
}
int main()
{
Xq::my_class<int> target(10);
return 0;
}
现象如下:
可以发现对于 const 成员属性来说,其不可以在构造函数的函数体内进行初始化;
因为const成员必须在定义的地方初始化,而初始化列表就是成员变量定义的地方;
1.1.2. 引用成员属性
那么引用成员属性可以在构造函数体内初始化吗?
测试 demo 如下:
namespace Xq
{
template<class T>
class my_class
{
public:
my_class(T val = T())
{
_val = val;
}
private:
T& _val; //引用成员属性
};
}
int main()
{
Xq::my_class<int> target(10);
return 0;
}
现象如下:
编译报错, 很显然, 引用成员属性也不可以在构造函数的函数体内进行初始化;
同理,引用成员属性也必须在定义的地方进行初始化,而初始化列表就是成员属性定义的地方;
1.1.3. 无默认构造的自定义成员属性
那么没有默认构造的自定义成员属性可以在构造函数体内初始化吗?
测试 demo 如下:
namespace Xq
{
class A
{
public:
A(int a) //非默认构造
{
_a = a;
}
private:
int _a;
};
template<class T>
class my_class
{
public:
my_class(int a = 1)
{
A aa(a);
_aa = aa;
}
private:
A _aa;
};
}
int main()
{
Xq::my_class<int> target(10);
return 0;
}
现象如下:
通过现象 (编译报错),我们知道,没有默认构造的自定义类型的成员属性同样不可以再构造函数的函数体内进行初始化;
1.1.4. 为什么需要有初始化列表
原因是某些情况下,在构造函数体内不能完成初始化工作,而这些成员属性必须在初始化列表进行初始化,分别有三种情况:
- const 成员属性;
- 引用成员属性;
- 没有默认构造的自定义类型成员属性;
对于上面三种数据类型, 需要要在初始化列表中进行初始化, 测试 demo 如下:
namespace Xq
{
class A
{
public:
A(int a) //非默认构造
{
_a = a;
}
private:
int _a;
};
template<class T>
class my_class
{
public:
my_class(int a = 1,T val = T(),const T memberval = T())
:_aa(a)
, _val(val)
, _memberval(memberval)
{}
private:
const T _memberval;
T& _val;
A _aa;
};
}
现象如下:
对于自定义类型成员属性(没有默认构造),必须在初始化列表中进行初始化(否则就会编译报错);
那么如果这个自定义类型有默认构造函数呢? 那么可不可以不在初始化列表中进行初始化呢? 测试demo如下:
namespace Xq
{
class A
{
public:
A(int a = 0) //默认构造
{
_a = a;
}
private:
int _a;
};
template<class T>
class my_class
{
public:
my_class(int a = 1)
{
my_class target(a);
_aa = target;
}
private:
A _aa;
};
}
- 如果这个自定义类型有了默认构造函数,要初始化 _aa 这个对象,可以在构造函数的函数体内进行赋值,但是还是会走初始化列表,通过初始化列表调用了这个自定义类型的默认构造;
- 因此不管这个自定义类型的成员属性有没有默认构造函数,我们都优先使用初始化列表进行初始化;
1.1.5. 初始化列表的总结
关于初始化列表的结论:初始化列表可以认为是成员属性定义的地方;
我们知道,在C++11中,打了一个补丁,可以给成员属性一个缺省值,具体如下:
namespace Xq
{
class my_class
{
public:
//...
private:
//声明
int _a = 1; //注意:这里不是初始化,而是给缺省值,如果初始化列表没有显式给值,那么就会使用这个缺省值
};
}
总结:
- 能使用初始化列表就尽量使用初始化列表(不管是内置类型成员属性还是自定义类型成员属性);
- 当然有些情况还是需要在构造函数的函数体内进行初始化;
1.1.6. 初始化列表的顺序
测试 demo 如下:
namespace Xq
{
class my_class
{
public:
my_class(int a)
:_a1(a)
, _a2(_a1)
{}
void print_info()
{
cout << "_a1: " << _a1 << " _a2: " << _a2 << endl;
}
private:
int _a2;
int _a1;
};
}
int main()
{
Xq::my_class aa(10);
aa.print_info();
return 0;
}
打印的结果是什么呢? _a1 = ? _a2 = ?
可能我们会认为_a1 = 10, _a2 = 10;那么结果真是如此吗?
运行完代码我们发现_a1 = 10,而_a2是一个随机值,这是为什么呢?
这是因为初始化列表的顺序是由成员属性声明顺序所决定的。
- 在这个类中,_a2先声明,_a1后声明;
- 也就是说在初始化列表当中先进行初始化的是_a2,后初始化 _a1;
- 而此时_a1是一个随机值,故 _a2 被初始化后就是一个随机值;
- 然后再对_a1进行初始化,也就是10;
- 因此,我们看到了这样的结果:_a1 = 10, _a2 是一个随机值。
因此结论就是:初始化列表初始化成员属性的顺序是成员属性声明的顺序;因此未来我们尽量让初始化顺序和成员属性声明的顺序保持一致;
2. explicit --- 禁止隐式类型转换
构造函数可以初始化对象,对于单个参数或者除第一个参数无默认值其余均有默认值 的构造函数,还具有类型转换的作用。
2.1. 单参数构造:
测试 demo 如下:
namespace Xq
{
class my_class
{
public:
my_class(int a) //单参数构造,支持隐式类型转换
:_a1(a)
{}
void print_info()
{
cout << "_a1: " << _a1 << endl;
}
private:
int _a1;
};
}
int main()
{
//直接调用构造
Xq::my_class aa1(10);
//构造(一个匿名对象,生命周期只有这一行) + 拷贝构造 + 优化(一些老的编译器可能不优化) = 直接构造
Xq::my_class aa2 = 20;
aa1.print_info();
aa2.print_info();
return 0;
}
现象如下:
但是如果我此时对这个单参数构造函数添加一个 explicit 呢?现象如下:
可以发现, 编译报错, 因为此时增加了 explicit 关键字,禁止了隐式类型转换,故编译报错;
2.2. 多参数默认构造
这个多参数默认构造要具有以下特征:
第一个参数无默认值,其余参数都有默认值,支持隐式类型转换 (全缺省也支持);
此时没有添加 explicit 关键字, 可以发生隐式类型转换, 现象如下:
当添加 explicit 关键字修饰该构造函数后, 无法发生隐式类型转换, 现象如下:
总结:用 explicit 修饰的构造函数,将会禁止构造函数的隐式转换;
匿名对象使用隐式类型转换很方便,并且它的生命周期只有那一行 ( 会调构造和析构 );
3. static成员
- 声明为 static 的类成员是类的静态成员。
- 用 static 修饰的成员变量,称之为静态成员变量;
- 用 static 修饰的成员函数,称之为静态成员函数。
- 静态成员变量一定要在类外进行初始化。
3.1. 静态成员属性
namespace Xq
{
class my_class
{
public:
my_class(int a = int())
:_a(a)
{
++count;
}
private:
//注意这里这是声明
int _a;
//非const静态成员属性必须在类外定义
static int count;
//但这里有一个特例,const静态成员属性在这里就是定义初始化
const static int num = 1;
};
}
int main()
{
return 0;
}
对于非 static 成员属性,是在初始化列表中进行初始化的,但是对于 static 成员属性我们可以在初始化列表中进行初始化吗?
测试 demo 如下:
从现象中可以知道:初始化列表中不能初始化 static 成员属性,那么也就意味着 static 成员属性是不可以给缺省值的,因为缺省值是为了给初始化列表用的,现象如下:
3.1.1. 静态成员属性需在类外初始化
因此,static 成员属性必须在类外定义,那么如何定义呢?
一般都是: 静态成员属性类型 (命名空间: : )类名: : 静态成员 = 特定值;
demo 如下:
namespace Xq
{
class my_class
{
public:
my_class(int a = int())
:_a(a)
//, count(0)
{
++_count;
}
private:
// 注意这里这是声明
int _a;
static int _count;
};
}
int Xq::my_class::_count = 0; //必须在类外给static成员属性定义;
int main()
{
return 0;
}
静态成员属性 (位于静态区) 属于这个类,不仅仅属于某一个对象,而是属于这个类的所有对象的。
- 静态成员属性属于整个类,不属于某个具体的对象,存放在静态区;
- 静态成员方法属于整个类,不属于某个具体的对象,存放在代码段;
- 静态成员属性必须在类外定义,定义时不添加 static 关键字,类中只是声明;
- 类静态成员即可用 类名: :静态成员 或者 对象.静态成员 来访问;
- 静态成员函数没有隐藏的 this 指针,不能访问任何非静态成员;
- 静态成员也是类的成员,受public、protected、private 访问限定符的限制;
3.2. 静态成员方法
接下来,我们用几个实例理解静态成员方法:
3.2.1. 实例一
假设现在我想计算一个类实例化对象的个数:
namespace Xq
{
class my_class
{
public:
my_class(int a = int())
:_a(a)
{
++_count;
}
// 注意这个static成员方法只能访问静态成员属性,
// 非静态成员属性无法访问,因为没有this指针;
static int get_count()
{
return _count;
}
private:
// 注意这里这是声明
int _a;
static int _count;
};
}
//必须在类外给static成员属性赋初始值,这里就是static成员属性的定义
int Xq::my_class::_count = 0;
int main()
{
Xq::my_class target[10];
//static成员方法只需要明确类域即可,因为它不属于某一个对象,是属于这个类的
std::cout << Xq::my_class::get_count() << std::endl;
return 0;
}
现象如下:
结果符合预期;
3.2.2. 实例二
假设我需要计算一个前n项的和:用static成员方法和成员属性如何实现呢?
namespace Xq
{
class my_class
{
public:
my_class(int a = int())
:_a(a)
{
_count++;
_sum += _count;
}
//注意这个static成员方法只能访问静态成员属性,非静态成员属性无法访问,因为没有this指针;
static int get_sum()
{
return _sum;
}
private:
// 注意这里这是声明
int _a;
static int _sum;
static int _count;
};
}
//必须在类外的全局域给static成员属性赋初始值,这里就是static成员属性的定义
int Xq::my_class::_count = 0;
int Xq::my_class::_sum = 0;
int main()
{
Xq::my_class target[10];
cout << Xq::my_class::get_sum() << endl;
return 0;
}
现象如下:
结果是符合预期的;
3.2.3. 实例三
还有一种static的应用场景,如果要求类实例化的对象只能在栈,如何实现?
namespace Xq
{
class my_class
{
public:
//只有通过这个类的静态成员函数实例化对象,并且这个对象一定在栈区上面
static my_class cread_obj()
{
my_class ret;
return ret;
}
private:
//默认构造设为私有,无法正常方式创建对象;
my_class(int a = int())
:_a(a)
{}
private:
int _a;
};
}
这里面很巧妙地运用了静态成员函数的特性,因为它不属于某一个对象,而是属于整个类,可以直接调用该静态成员函数,而不需要先实例化对象。
3.2.4. 静态成员方法的总结:
- 静态成员方法属于整个类,不属于某个具体的对象;
- 调用静态成员方法只需要明确类域即可;
- 非静态成员方法可以调用静态成员方法;
- 静态成员方法没有 this 指针,因此不能直接访问任何非静态成员;
- 但有时候,如果静态成员方法需要访问类的非静态成员, 那么我们可以将 this 指针作为参数传递给静态成员方法,通过this指针访问非静态成员,在线程的封装中经常使用。
4. 友元
友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。友元分为:友元函数和友元类;
4.1. 友元函数
友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部声明,声明时需要加 friend 关键字。
- 友元函数可访问类的私有和保护成员,但不是类的成员函数;
- 友元函数不能用const修饰;
- 友元函数可以在类定义的任何地方声明,不受类访问限定符限制;
- 一个函数可以是多个类的友元函数;
- 友元函数的调用与普通函数的调用原理相同;
友元函数 demo 如下:
class A
{
// 友元函数不受访问权限约束
friend void friend_print(A&);
private:
void print()
{
std::cout << "haha" << std::endl;
}
private:
int _val = 10;
};
void friend_print(A& target)
{
std::cout << target._val << std::endl;
target._val = 20;
std::cout << target._val << std::endl;
std::cout << "-------------------------------" << std::endl;
target.print();
}
int main()
{
A a;
friend_print(a);
return 0;
}
现象如下:
可以看出, 友元函数可以访问该类的私有成员和保护成员。
但是, 虽然提供了这种便利, 但是破化了类的封装, 因此要根据情况使用。
4.2. 友元类:
- 友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员;
- 友元关系是单向的,不具有交换性;
- 友元关系不能传递。如果C是B的友元, B是A的友元,则不能说明C是A的友元;
- 友元关系不能继承。
namespace Xq
{
class A
{
//声明B类为A类的友元类,则在B类中可以直接访问A类的非公有成员
friend class B;
public:
//...
};
class B
{
public:
//...
};
}
4.3. 内部类
- 内部类指的是在另一个类内部定义的类;
- 内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员;
- 外部类对内部类没有任何优越的访问权限;
- 内部类就是外部类的友元类,内部类可以通过外部类的对象来访问外部类中的所有成员;
- 但是外部类不是内部类的友元。
4.3.1. 内部类的特性:
- 内部类定义在外部类的 public、protected、private 都是可以的。
- 注意内部类可以直接访问外部类中的 static 成员,不需要外部类的对象/类名。
- sizeof(外部类)=外部类,和内部类没有任何关系。
根据上面所说, 我们用代码验证一下 sizeof 外部类 等于什么呢?
namespace Xq
{
class A
{
public:
//...
private:
int _a;
class B
{
public:
// ...
private:
int _b;
};
//...
};
}
int main()
{
Xq::A a;
cout << sizeof a << endl; // a是多大呢 4 / 8 ?
return 0;
}
现象如下:
答案是4,也就是对于一个类实例化的对象来说,计算大小的时候只包括外部类,不考虑内部类;
4.3.2. 内部类受到外部类的类域限制和访问限定符的限制
因此, 我们需要突破类域限制,如下:
4.3.3. 内部类 && 友元类
B是A的内部类 --> B天生是A的友元类 --> 也就是说B可以访问A的非公有成员;
namespace Xq
{
class A
{
public:
//...
private:
int _a;
class B
{
public:
// ...
void func(const A& aa)
{
cout << aa._a << endl;
}
private:
int _b;
};
//...
};
}
可以看到B是A的内部类,即B是A的友元类(具有单向性),它可以通过A对象访问A的私有成员属性;
如下:
namespace Xq
{
class A
{
public:
//...
private:
int _a;
static int _t; //静态成员属性
class B
{
public:
// ...
void func(const A& aa)
{
cout << aa._a << endl;
cout << _t << endl; //可以直接访问外部类的静态成员属性
}
private:
int _b;
};
//...
};
}
int Xq::A::_t = 1;
当B是A的内部类时,那么B就是A的友元类,B中可以直接访问A的静态成员属性,因为静态成员属性只需要突破类域的限制即可访问;
5.拷贝对象时的一些编译器优化
5.1. 构造 + 拷贝构造 的优化
构造 + 拷贝构造 ---- 编译器会优化直接调用构造;
连续的一个表达式步骤中,连续构造就会优化。
让我们探讨一下编译器对拷贝对象时发生的一些优化;
下面的A类是为了让我们更好的观察现象。
namespace
{
class A
{
public:
A()
{
cout << "A()" << endl;
}
A(const A& copy)
{
cout << "A(const A& copy)" << endl;
}
~A()
{
cout << " ~A() " << endl;
}
};
}
5.1.1. 传值传参会发生拷贝
现象如下:
5.1.2. 传引用不会发生拷贝构造
5.1.3. 值传递(这个值是一个匿名对象)
void func(Xq::A _aa)
{
}
int main()
{
func(Xq::A()); //一个匿名对象
return 0;
}
我们的预期是: 匿名对象会构造,然后值传递发生拷贝构造;
那么应该是 一次构造 + 一次拷贝构造 + 两次析构,让我们看看结果:
即连续的一个表达式步骤中,构造 + 拷贝构造 会被编译器优化 ---> 直接调用构造;
还有一个我们遇见的实例, 具体如下:
namespace Xq
{
class A
{
public:
A(int a = int())
:_a(a)
{
cout << "A()" << endl;
}
A(const A& copy)
:_a(copy._a)
{
cout << "A(const A& copy)" << endl;
}
~A()
{
cout << " ~A() " << endl;
}
private:
int _a;
};
}
int main()
{
//预期: 这个对象直接调用构造
Xq::A a1(1);
//预期: 先会生成一个匿名对象,然后调用拷贝构造实例化a2
Xq::A a2 = 2;
return 0;
}
现象如下:
5.1.4. 传值返回:
namespace Xq
{
class A
{
public:
A()
{
cout << "A()" << endl;
}
A(const A& copy)
{
cout << "A(const A& copy)" << endl;
}
~A()
{
cout << " ~A() " << endl;
}
};
}
Xq::A func()
{
Xq::A ret;
return ret;
}
int main()
{
func();
return 0;
}
我们以前说过,传值返回会生成一份临时对象,返回的是临时对象,那么也就是说,在返回时会调用拷贝构造;
测试 demo 如下:
可以看出, 传值返回会调用拷贝构造;
接下来,我修改一下代码看看会发生什么 ?请看代码
int main()
{
Xq::A target = func(); //仅仅增添了一个对象用来接收返回值
return 0;
}
我们的预期是:
func() 里面是构造 + 拷贝构造;
然后要实例化target,因此还要再发生一次拷贝构造;
即一次构造,两次拷贝构造,三次析构;
测试现象如下:
因为 func() 返回的时候会调用拷贝构造生成一个临时对象,这个临时对象又要进行拷贝构造target,编译器对这种连续的拷贝构造会进行优化处理,即一个表达式中,连续的拷贝构造 + 拷贝构造 ---> 优化成 一个拷贝构造;
但是我有一个问题,既然你说编译器会优化成一次拷贝构造,这我能理解,也接受,因为结果在那里。
但是你优化成一次拷贝构造的话,既不生成这个临时变量了,那么这个 ret对象 怎么返回给target 啊?
不是说出了函数栈帧,对象会被销毁吗,这难道不会非法访问吗?
OK如果你有这样的问题,那么请继续看下面:
首先,如果编译器不优化应该是这样的:
那么你告诉我编译器要优化,无非就是这种,不生成临时对象,在 func() 未结束之前让 ret 充当这个临时对象;
但是,我一看,不对啊,这个 ret 不是在 func() 这个栈帧里面吗,func() 函数调用完,栈帧需要被销毁,难道它不销毁吗?
如果它被销毁了,你这不就是非法访问吗?OK,问题就在这。
首先我们需要知道,传值返回的这个值如果比较小 (4 || 8 byte) 那么会存储于寄存器里;
如果比较大那么会存储在上一层函数栈帧里;
具体原则是:返回值优化(Return Value Optimization,RVO)或者命名返回值优化(Named Return Value Optimization,NRVO);
返回值优化和命名返回值优化是编译器优化的一种方式,旨在减少不必要的对象拷贝和构造开销。
它们通过在调用函数时直接构造返回值对象的目标位置,而不是创建一个临时对象然后再拷贝给目标位置,来实现这一优化。
返回值优化(RVO): 当函数 func() 返回一个非临时(非局部)对象时,编译器可以直接在调用 func() 的地方构造 target 对象,避免产生额外的拷贝构造开销。这样,ret对象从 func() 直接构造到 target,而不是先构造临时对象再拷贝给 target。
命名返回值优化(NRVO): 当函数 func() 中已经声明了 ret 对象,并且这个对象被命名为返回值,编译器可以将对象 ret 直接作为函数返回值的目标位置进行构造,从而避免了不必要的拷贝构造。也就是说,ret 对象直接在调用 func() 并返回时被构造到 target。
最后需要注意的是:返回值优化和命名返回值优化是编译器的行为;在标准中没有明确规定,编译器是否会进行这种优化;
因此,在这里编译器的优化是有原则的,不会造成非法访问;
5.1.5. 关于优化的例题
在这里有两个题目,更能让我们理解这些优化:
我们还是以A类举例,具体如下:
namespace Xq
{
class A
{
public:
A()
{
cout << "A()" << endl;
}
A(const A& copy)
{
cout << "A(const A& copy)" << endl;
}
~A()
{
cout << " ~A() " << endl;
}
};
}
题目一:调了几次构造?几次拷贝构造?
Xq::A func(Xq::A u)
{
Xq::A v(u);
Xq::A w = v;
return w;
}
int main()
{
Xq::A x;
Xq::A y = func(x);
return 0;
}
分析如下:
分析结果: 1次构造、 4次拷贝构造;
测试现象如下:
符合我们分析的预期。
题目二:多少次构造? 多少次拷贝构造?
Xq::A func(Xq::A u)
{
Xq::A v(u);
Xq::A w = v;
return w;
}
int main()
{
Xq::A x;
Xq::A y = func(func(x));
return 0;
}
分析如下:
分析结果: 1次构造、 7次拷贝构造;
测试现象如下:
符合我们的预期。
OK,最后在验证最后一个问题:
namespace Xq
{
class A
{
public:
A()
{
cout << "A()" << endl;
}
A(const A& copy)
{
cout << "A(const A& copy)" << endl;
}
A& operator=(const A& target)
{
cout << "A& operator=(const A& target)" << endl;
return *this;
}
~A()
{
cout << " ~A() " << endl;
}
};
}
Xq::A func()
{
Xq::A ret;
return ret;
}
int main()
{
Xq::A x;
x = func(); //注意这里是赋值,不是拷贝构造;
return 0;
}
那么我的问题是:在一个表达式中,连续的拷贝构造 + 赋值运算符重载会优化吗?
5.2. 编译器在拷贝对象时的优化总结
- 隐式类型转换,构造 + 拷贝构造 -> 优化为直接构造;
- 一个表达式中,构造 + 拷贝构造 -> 优化为一个构造;
- 一个表达式中,拷贝构造 + 拷贝构造 -> 优化一个拷贝构造;
- 一个表达式中,拷贝构造 + 赋值重载 -> 无法优化。
最后,关于这些优化都是和编译器有关的,优不优化,优化的程度都是由编译器决定的,标准没有明确规定;
6. 再次理解封装
类是描述某一个实体对象的,描述该对象具有哪些属性,哪些方法,描述完成后就形成了一种新的自定义类型,而这个过程就是封装。
在C++中,封装(Encapsulation)是一种面向对象编程(OOP)的概念,用于将属性和对属性的操作(方法)组合在一个单元中。
它将属性和方法相关联,并限制了对属性的直接访问,只能通过特定的接口来访问和操作属性。
封装目的:
- 为了隐藏内部实现细节,提供对外的简洁接口,同时保护数据的完整性和安全性。
- 这样,使用类的代码可以更加清晰、简洁,并且可以更好地控制数据的访问权限。
- 在C++中,封装通常通过类来实现。
- 类将成员属性和成员方法封装在一起,成员属性通常被声明为私有(private),只能通过类的公有(public)成员方法进行访问。
- 这样可以确保外部代码不能直接访问和修改类的属性,而是通过提供给外部的公有接口来间接操作属性;
这样带来的好处是:
- 外部代码只需要关注如何使用公有接口提供的功能,而不需要了解内部的实现细节。
- 这提供了更好的代码模块化和复用性,并且可以防止数据被误用或意外修改。
- 此外,封装还为类的实现者提供了更大的灵活性,在保持接口稳定的情况下可以随时改变内部实现