类
不建议使用基类数组储存派生类对象,这样在索引时会导致索引错误:
|
构造函数
首先了解下面几个构造函数:
构造函数 | 解释 |
---|---|
默认构造函数 | 在调用时可以不提供任何参数 |
复制构造函数 | 唯一参数为 const 限定的左值引用 |
移动构造函数 | 唯一参数为右值引用 |
例如:
class A{
public:
A(); // 默认构造函数
A(int a = 10); // 默认构造函数
A(const A&b); // 复制构造函数
A(A&& b); // 移动构造函数
};
当类中存在多个默认构造函数时在类创建时将会导致歧义,所以应当避免此情况出现。 以下方式调用的均为默认构造函数:
使用
看起来似乎能解决类型匹配的问题,但实际上这会被当作函数声明。 |
委托构造
委托构造函数可以在构造函数的初始化列表中调用另外一个构造函数,从而达到简化代码的目的:
class Parent{
int a;
int b;
public:
Parent() = default;
Parent(int x, int y) : Parent(){
a = x;
b = y;
}
};
当使用委托构造函数时,初始化列表中只能有被委托的构造函数,例如:
是不合法的。 |
继承构造
当父类的构造函数参数十分多,而且父类的构造函数可以直接在子类中用的时候可以通过 using
继承父类的构造函数:
class Child : public Parent{
using Parent::Parent;
};
using Parent::Parent
将会继承所有父类的构造函数。当子类存在和父类同名的构造函数时,子类构造函数将覆盖父类构造函数。
复制构造和移动构造
例如:
A a; // 仅调用默认构造函数
A b = A(); // 仅调用默认构造函数
A c = a; // 仅调用复制构造函数
A d = std::move(a); // 仅调用移动构造函数
A g = d; // 复制构造函数
C++ 中的拷贝构造函数的参数只能传递引用,而不能传递值。因为传递值的话就会在传值过程中发生拷贝过程,从而导致这一过程无休止地进行下去。因此,拷贝构造函数是不允许传值的,这种写法会直接导致编译失败 |
原地初始化和初始化列表[1]
对类的成员变量进行初始化一般有两种方法:原地初始化和初始化列表。此外,还有一种在构造函数中的“初始化方法”。其形式如下:
class ScreenShot {
QString str1("str1"); // 原地初始化
QString str2 = QString("str2");
QString str3{"str3"};
QString *str4 = new QString("str4");
public:
ScreenShot()
: str2("haha") { // 初始化列表
str3 = "666"; // 赋值
}
};
C++ 在进入构造函数之前就已经将对象构造好了,构造函数体内的属于赋值,初始化列表中的才是初始化 |
在对成员变量进行原地初始化的时候是无法使用圆括号表示法调用默认构造函数的,这是因为编译器会将其解析为函数声明:
解决办法有两种:
|
std::string a(())
的形式是行不通的就地初始化的时候是不能用 auto 的
类的隐式类型转换
类中,存在以下两种形式的类型转换:
class B;
class A{
public:
A(const B&); // 允许 B --> A 的隐式类型转换
operator B(); // 允许 A --> B 的隐式类型转换
};
如果不希望隐式类型转换,可以在函数前使用 explicit
以禁用隐式类型转换,但是依然可以显式转换。
除非有一个好的理由允许构造函数进行隐式类型转换,否则应当将其声明为 `explicit`[1] |
禁止生成函数
默认情况下,编译器将会为我们的类添加一些构造函数以用于构造、拷贝、移动等操作,这些函数如果被人为生成,则编译器不再生成。但是我们依然可以使用 =default 来让它生成默认函数或使用 =delete 禁止其生成函数
// 使用delete删除函数
class Parent {
public:
Parent(const Parent) = default; // 生成默认的拷贝函数
Parent() = delete; // 禁止生成对象
};
操作符重载
C++使用 operator@ 来重载C++本身已有的运算符,其中 @ 表示运算符。
运算符重载是函数调用的另外一种方式,目的是为了 语法上的方便 或 便于阅读代码 。
语法:
operator@的语法分为两种:
运算符是全局函数:
当运算符是全局函数时,一元运算符必须提供一个参数,二元运算符必须提供两个参数。运算符前的对象将作为第一个参数,运算符后的对象将成为第二个参数。
运算符是成员函数:
当运算符是成员函数(类成员函数)时,一元运算符没有参数,二元运算符提供一个参数。类对象将作为运算符的第一个参数。
operator=只允许作为成员函数。 |
前缀运算符和后缀运算符
典型代表是 ++
和 --
,这两个运算符既可以出现在对象前,又可以出现在对象后。
C++委员会对后缀运算符新增了一个 哑元常量值
用来与前缀进行区别。实现方式是对后缀运算符新增加一个参数。并且这个参数不能被使用。也就是说,这个新增的参数只是用来表示“这个是后缀运算符”的。
使用运算符进行自动类型转换
创建一个成员函数,这个函数为: operator 要转换到的类
,这个成员函数没有返回类型—返回类型就是正在重载的运算符名字。
这个函数将在需要类型转换时自动调用。
运算符重载在成员和非成员函数中的选择
Murray为在成员和非成员之间的选择提出如下方针:
运算符 | 建议使用 |
---|---|
所有一元运算符 | 成员 |
= () [] → →* | 必须是成员 |
+= -= /= *= ^= &= = %= >>= <⇐ | 成员 |
所有其他二元运算符 | 非成员 |
&&、||和逗号操作符 不应当被重载[2] |
比较运算符
比较运算符的重载一共有六种:<,>,==,!=,>=,⇐
重载方式有一下几种:
实现复合运算符,普通运算符则调用复合运算符,这种方式只需要维护符合运算符的规则,比较简单[3]
只实现 == 和 < ,然后导入 std::rel_ops 。这种方式会为所有未定义比较运算符的类导入这些重载[4] 这种方式在 C++ 20 中被弃用
使用 Boost.operators 。这种方式使用多继承的方式来简化重载符重载
使用 C++ 20 引入的 三路比较 。这种方式实现简单,而且不区分左手运算符和右手运算符
前三种比较简单,下面着重介绍三路比较:
三路比较运算符的函数签名为 operator<⇒ 返回值和参数根据写法不同略有变化。最简单的方式就是让编译器默认生成一个:[5]
std::strong_ordering operator<=>(const Name&) const = default;
函数的返回值有以下几种:
数值 | ||||
---|---|---|---|---|
类型 | -1 | 0 | +1 | 非数值型 |
strong_ordering | less | equal | greater | |
weak_ordering | less | equivalent | greater | |
partial_ordering | less | equivalent | greater | unordered |
strong_equality | equal | nonequal | ||
weak_equality | equivalent | nonequivalent |
这里的类型一栏,均为 <compare>
中定义的类,而 less 等是其成员变量。 less, equal 等数值类型可以隐式转换到数值类型。
类型的关系如下:
他们有以下不同:
- strong_ordering
比较所有成员
字符比较区分大小写
可以完全依赖编译器
- weak_ordering
比较部分成员
字符比较不区分大小写
需手动实现
operator<⇒ 的返回值是上述类型之一,因此在 operators<⇒ 中需要使用三路比较符进行比较:
struct Student {
int age = 0;
string name;
weak_ordering operator<=>(Student const& b)const{
return name <=> b.name;
}
};
int main() {
Student a{10, string("XiaoMing")};
Student b{9, string("XiaoHong")};
cout<<(a<b); // 这里也可以用 a<=>b==0
return 0;
}
如果成员变量中具有容器,那么需要单独为容器做一个 operator== ,这是因为编译器生成的比较代码中会对容器中的成员逐一比较再产生结果,但是有时候只需要比较 size 就行了
对于多成员变量比较而言,可以使用 std::tie 简化操作 |
成员函数
const 限定的成员函数[2]
现在,将 const 写到成员函数的签名后,有以下含义:
此函数内部不会对类的属性进行任何修改
根据 const 可对函数进行重载
若存在由 const 区分的函数重载,则 const 对象调用 const 限定的函数, 无 const 限定的对象调用 无 const 限定的成员函数
class A{
public:
int operator[](std::size_t index){}
int operator[](std::size_t index) const{}
};
int main(){
A a;
a[10]; // 调用 operator[](std::size_t index)
const A b;
b[10]; // 调用 operator[](std::size_t index) const
return 0;
}
在某些情况下你可能希望在 const 限定的成员函数里修改某些类属性,这是你可以使用 mutable
修饰该属性,这将导致该属性总是可以修改的(无论是否在 const 限定的成员函数中)
valatile 限定的成员函数[3]
与 const 类似,valatile 也可以用来区分函数重载。只有 valatile 对象才可以调用 valatile 成员函数,其表明对象可能在编译器无法预料的情况修改。编译器不要瞎优化了。
& 和 && 限定的成员函数
用来表示只能用由左值/右值调用的函数
深拷贝和浅拷贝
当使用 复制构造函数
或者 赋值运算符
时会发生对象之间的拷贝,其行为有两种结果:
浅拷贝:C++ 对类中的成员执行简单的复制,两个对象指针成员指向的地址将会相同
深拷贝:在执行复制时,对与指针成员将会分配内存,两个对象的指针成员指向的地址不同。
浅拷贝如:
class A{
char* str;
public:
A(const A& a){
str = a.str; // 此时两个对象的 str 指向同一个地址
}
};
深拷贝例如:
class A{
char* str;
public:
A(const A & a){
free(str);
str = static_cast<char*>(malloc(strlen(a.str))); // 此时两个对象的 str 指向不同的地址
for (int i = 0; i < strlen(a.str) ; ++i) {
str[i] = a.str[i];
}
}
};
C++ 为了效率的考虑,默认执行的浅拷贝,这意味着你必须小心地使用对象,以防止对指针出现多次析构。此类错误在发生隐式类型转换时更加隐蔽 |
引用计数和写时复制
引用计数和写时复制是为了解决程序效率的问题,其原则如下:
每个对象都储存了一个计数器,用来表示有几个引用指向它
每当对象被复制时,不会真的复制,而只是将计数器加一
每当引用被析构时,对象的计数器减一
当且仅当对象被修改时,对象才会真的复制(写时复制)
例如:
class STR{
int* counter = new int(0);
char* str = nullptr;
public:
explicit STR(const char* str){
this->str = (char*)malloc(strlen(str));
for (int i = 0; i < strlen(str); ++i) {
this->str[i] = str[i];
}
}
STR(STR& b){
++b.counter; // 引用计数器加一
counter = b.counter; // 执行浅拷贝
str = b.str; // 执行浅拷贝
}
STR& operator=(STR&b){
++b.counter;
counter = b.counter; // 执行浅拷贝
str = b.str; // 执行浅拷贝
return *this;
}
~STR(){
if(*counter == 0){
free(counter);
free(str);
}
else --*counter;
}
void setChar(int index, char ch){
// 这个函数需要修改类的属性
// 执行深拷贝
const char* tmp = str;
counter = new int(0);
str = (char*)malloc(strlen(tmp));
for (int i = 0; i < strlen(tmp) ; ++i) {
str[i] = tmp[i];
}
str[index] = ch;
}
};
继承
函数重写
函数重写用来父类暴露的虚函数。为了避免重写父类函数时写错函数名,C++11
添加了 override
关键字,用于在编译时检查父类时候存在 同名虚函数
。
class Parent {
virtual void fun(int){};
};
class Child : public Parent{
void fun(int) override{};
};
函数覆盖[4]
函数重写发生在虚函数中,而函数覆盖则会发生在非虚函数中。子类成员函数将会覆盖父类同名函数
。一旦子类中存在与父类同名的函数,那么父类中此函数的所有重载形式都将不可用
要在子类中暴露父类函数,可以通过 using Parent::
的方式将父类函数中的所有同名函数导出到子类。这种情况下,只有函数签名完全相同的父类函数会被覆盖,而签名不同的重载形式不会被覆盖。
此外,若你希望仅仅暴露父类某个函数重载形式,可以通过函数进行转发:
class Child : public Parent{
public:
void eatFood(){
Parent::eat();
}
};
终止重写
若不希望子类重写某些函数,则可以添加 final
关键字:
class Parent {
virtual void fun(int) final {}; // 该函数无法被子类重写
};
虚函数
虚有析构函数:
查看以下代码:
class Parent{
public:
virtual ~Parent(){
cout<<__FUNCTION__<<endl;
}
};
class Child: public Parent{
public:
~Child(){
cout<<__FUNCTION__<<endl;
}
};
int main(){
Parent* par = new Child();
delete par;
return 0;
}
此时若父类析构函数不是虚函数,则 delete par
只会调用父类析构函数
如果子类可能重写父类的析构函数,那么父类的析构函数应当定义为虚有 条款三十二 <More Effective C++> |
C++ 构造和析构的时机
成员变量按照其声明的顺序进行构造
先初始化基类,再初始化派生类
先析构派生类,再析构基类
|
公有继承[5]
当你的通过 public 的方式构建你的继承方式时,所有父类函数都将可以被子类对象调用,这意味这 子类对象 is a 父类对象
,这就是 OOP 中大名鼎鼎的 is-a
关系。
is-a 关系意味着 子类是父类的特例,父类是子类的抽象,任何使用于父类的方法也同样适用于子类。 例如:
所有的学生都是人,但不是所有的人都是学生。人是父类,学生是子类,学生是人,所有人可以干的事学生也可以干。
在公有继承的情况下应当谨慎地审视父类和子类的对象,假设父类鸟有一个方法为 fly()
,而企鹅继承自鸟类,但是企鹅并不会飞。
尽管你可以在企鹅类中重写 fly() 方法,让它在被调用时抛出一个异常,但是这个问题将会导致编译期问题被延长到了运行期
接口继承和实现继承[6]
在某些情况下你可以希望对子类抱有以下希望:
子类必须要重写父类提供的函数。
子类可以使用父类函数的默认实现
子类必须显式使用父类函数的默认实现
子类不得重写父类提供的函数实现
C++ 对于上述需求提供一下方案:
使用抽象类使子类必须实现父类函数 这种情况被成为
接口继承
。父类对用户提供接口,子类提供实现。使用简单的虚函数可以让子类可以使用父类函数的默认实现 这种情况下意味着“子类应当实现虚函数,如果你不想实现,也可以使用父类提供的版本”
使用“带有实现的纯虚函数”可以让子类必须显式使用父类的函数 这种情况一般用于“父类提供的实现只能覆盖大部分情况,但是少数情况例外”。
使用 final 关键字可以防止子类更改父类函数
例如:
class Parent{
public:
virtual void eat() = 0;
};
void Parent::eat() {
cout<<"Parent eat"<<endl;
}
class Child : public Parent{
public:
void eat() override { // 此函数子类必须要实现
Parent::eat(); // 使用父类提供的默认实现
}
};
纯虚函数的声明和默认实现必须要分离。如果实现了纯虚函数,其依然需要在之类中重写,但是可以显式调用父类实现 |
替换虚函数[7]
在某些情况下,你可能需要替换虚函数,而使用其他实现。比如你需要在虚函数被调用之前进行加锁、添加日志、设定场景等一些准备工作和清理工作。而这些依赖于用户显然是不现实的,在《Effective C++ 第三版》中提出了以下几种方式:
使用
NVI(Non-Virtual-Interface),手法
。该手法将要被调用的虚函数声明为 protect, 而调用虚函数的包装器则不是一个虚函数。该手法是 Template Method 设计模式 的一种特殊形式使用
函数指针
。此方法可以在运行时动态地调整包装器调用的函数,不同的对象甚至可以使用不同的函数使用
std::functional
。此方法与 函数指针 手法类似,但是可以接受 函数对象 和 lambda 表达式使用
Strategy 设计模式
。将要被调用的虚函数转换为一个类体系。使用 奇异模板递归[6]
例如:
// 使用 std::functional
class Human{
std::function<int ()> calHeath;
public:
explicit Human(std::function<int ()> f):calHeath(f){}
int Heath(){
return calHeath();
}
};
// 使用 Strategy
class Heath{
public:
virtual int calc() = 0;
};
class Man{
Heath* heath;
public:
explicit Man(Heath* h):heath(h){}
int Heath(){
return heath->calc();
}
};
继承和组合
简单而言,
继承通过拓展已有的类获得新功能。继承关系是 is-a
组合通过添加数据成员获得功能。组合关系是 has-a
一般而言,组合优于继承,除以下情况外都应当使用组合:
需要用父类指针/引用操纵子类对象
需要通过父类向子类添加功能。例如 std::enable_shared_this 和 boost::operators
父类是一个空类,这时使用组合会带来一个字节的开销,这时也可以使用继承。例如
C++ 中的对象计数
实际上,私有继承就是另一种方式的组合
指针分布
对于单继承而言,使用将派生类对象赋值给基类指针时不会有任何改变。例如:
class BaseX {};
class ClassY: public BaseX {};
class FatherZ :public ClassY {};
int main(){
FatherZ aObject;
BaseX* ptrX=&aObject;
ClassY* ptrY=&aObject;
FatherZ* ptrZ=&aObject;
return 0;
}
这里三个指针的值相同。
但是对于多继承而言,只有继承列表中的第一个类的指针与原对象地址相同
对象切片
以下面的例题为例:
class A{
public:
long a;
};
class B : public A {
public:
long b;
};
void seta(A* data, int idx) {
data[idx].a = 2;
}
int main(int argc, char *argv[]) {
B data[4];
for(int i=0; i<4; ++i){
data[i].a = 1;
data[i].b = 1;
seta(data, i);
}
for(int i=0; i<4; ++i){
std::cout << data[i].a << data[i].b;
}
return 0;
}
程序的输出结果为:
22221111
其原因是在 seta 中发生了对象切片。
类 A,B 均无多态,因此在内存分布中 sizeof(A) == 4, sizeof(B) == 8,在 data 传入 seta 后发生了对象切片,此时 data 数组完整的长度是 4x2 = 8,而不是 4(这时数组内储存的类型为 A)。因此在前四次迭代中实际上分别修改的是 data[0].a, data[0].b, data[1].a, data[2].b
二进制兼容
所谓的二进制兼容主要是针对动态库,指底层库更改代码后依赖它的软件即使是不重新编译也能运行。
二进制兼容的本质就是在更新源代码的时候不要更改对象布局,可用的方法有:
将实现分离到私有类中(比如 D 指针)
给函数添加了默认参数
调整了数据成员的声明顺序
添加或减少了数据成员
更改了内存对齐
更改了虚函数的数量
不要添加内联函数
不要更改函数默认参数
下面方法一般不会造成二进制兼容问题:
添加非虚函数
添加新的类
struct 和 class
C++中的 struct 和 class 基本是通用的,唯有几个细节不同:
class 默认是 private 的,而 struct 默认是 public
class 继承默认是 private 继承,而 struct 继承默认是 public 继承
class 可以使用模板,而 struct 不能
delete this
为什么需要delete this?
delete this,可以让某种类型的对象拥有自杀的能力。有些设计模式,如状态模式,在状态转换可能需要使用delete this。
使用delete this使用的注意事项
要保证对象被分配到heap内,this对象是必须是用 new操作符分配的(亦不能用placement new,因为无法确定内存位置是否在heap内)
delete this后,不能访问该对象任何的成员变量及虚函数(delete this回收的是数据,这包括对象的数据成员以及vtable,不包括函数代码);
delete this后,不能再访问this指针。换句话说,你不能去检查它、将它和其他指针比较、和 NULL比较、打印它、转换它,以及其它的任何事情;
如何要求对象产生于Heap内
根据《More effective {cpp}》书中条款27,保证以上禁忌列表基本手段: * 将析构函数protected化,保证继承有效,同样保证对象必须使用new在堆上分配内存; * 在基类中提供型如destroy()函数,里面仅有一句delete this--以保证第三方能够将分配的内存回收; [, ]
void destory const() {delete this;}
=== 使用类模拟枚举 例如: [, cpp]
struct Style final { static Style Row; static Style Col; static Style Square; int state_ = 0; explicit Style(int state) : state_(state) { } // QString -→ Style Style(const QString& str) { if(str == "Row") state_ = 0; else if(str == "Col") state_ = 1; else if(str == "Square") state_ = 2; } // Style -→ QString operator const QString() { switch(state_) { case 0: return QStringLiteral("Row"); case 1: return QStringLiteral("Col"); case 2: return QStringLiteral("Square"); } return QStringLiteral(""); }
bool operator==(Style const& b) { return this->state_ == b.state_; }
bool operator==(QString const& b) { return this->state_ == (Style)b; } };
Style Style::Row = Style(0); Style Style::Col = Style(1); Style Style::Square = Style(2);