第十一章 C++面向对象设计
# 第十一章 C++面向对象设计
在接下来大约20个问题中,我们将探讨面向对象设计的一些内容、继承、C++如何处理多态、奇特递归模板模式(Curiously Recurring Template Pattern)等等。
# 问题77:类(class)和结构体(struct)之间有什么区别?
在C++中,类和结构体之间的区别很小。具体来说,结构体中成员变量和方法的默认访问权限是公共(public)的,而类中是私有(private)的,仅此而已。在实践中,许多程序员仅将结构体类型用作存储类。这可能是受C语言的影响,在C语言中,结构体不支持函数(或方法)。
相反,在C++中,结构体既可以有函数、构造函数,甚至可以有虚函数(virtual functions)。它们可以使用继承,也可以被模板化,与类完全一样。
当谈到默认可见性时,值得一提的是它也适用于继承。当你写class Derived : Base ...
时,得到的是私有继承;另一方面,当你写struct Derived: Base ...
时,是公共继承。
正如我们所见,两者在技术上没有太大区别,但根据既定惯例,我们使用它们所表达的含义却大不相同。
结构体是一组相关元素的集合,通常不封装任何逻辑。它只是为了有效地将事物组合在一起,提高抽象程度。
同时,类通常会执行一些操作。它封装数据和相关逻辑,提供一个接口,在一定程度上,将接口与实现分离。一般来说,如果你至少有一个私有成员,最好使用类。
以下是一些核心准则,对这个主题进行了更详细的讨论:
- C.1:将相关数据组织成结构体(structs)或类
- C.2:如果类有不变量,使用类;如果数据成员可以独立变化,使用结构体
- C.3:使用类来表示接口和实现之间的区别
- C.8:如果有任何成员是非公共的,使用类而不是结构体
# 问题78:什么是构造函数委托(constructor delegation)?
构造函数委托是指一个构造函数可以调用同一个类中的另一个构造函数。自C++11起,这在C++中成为可能。
构造函数委托有助于通过消除类初始化中的代码重复来简化代码。
class T {
public:
T() : T(0, ""){}
T(int iNum, std::string iText) : num(iNum), text(iText){};
private:
int num;
std::string text;
};
2
3
4
5
6
7
8
在上面的例子中,我们可以看到默认构造函数只是调用了接受两个参数的构造函数。当你有更多成员,并且需要添加一个新成员时,这就很实用了。你只需要更新直接初始化所有成员的构造函数,编译器会提示你在所有需要传入额外变量的地方进行修改。
# 问题79:解释协变返回类型(covariant return types)的概念,并举例说明它在哪些场景中很有用!
对于虚函数及其所有重写版本使用协变返回类型,意味着你可以用更具体的类型替换原始返回类型,换句话说,就是用更专业的类型。
假设你有一个汽车工厂生产线(CarFactoryLine)生产汽车。这些工厂生产线的特定版本可能生产运动型多用途汽车(SUV)、跑车(SportsCars)等。如何在代码中表示呢?最明显的方法仍然是将返回类型设为汽车指针(Car pointer)。
class CarFactoryLine {
public:
virtual Car* produce() {
return new Car{};
}
};
class SUVFactoryLine : public CarFactoryLine {
public:
virtual Car* produce() override {
return new SUV{};
}
};
2
3
4
5
6
7
8
9
10
11
12
13
这样,获取一个SUV*
需要进行动态类型转换(dynamic cast)。
SUVFactoryLine sf;
Car* car = sf.produce();
SUV* suv = dynamic_cast<SUV*>(car);
2
3
相反,我们可以直接返回一个SUV*
。
class Car {
public:
virtual ~Car() = default ;
};
class SUV : public Car {};
class CarFactoryLine {
public:
virtual Car* produce() {
return new Car{};
}
};
class SUVFactoryLine : public CarFactoryLine {
public:
virtual SUV* produce() override {
return new SUV{};
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
这样你就可以直接这样做:
SUVFactoryLine sf;
SUV* car = sf.produce();
2
在C++中,在派生类的重写函数中,你不必返回与基类相同的类型,而是可以返回一个协变返回类型。换句话说,你可以用一个“更窄”的类型替换原始类型,也就是用一个更具体的数据类型。
# 问题80:函数重载(function overloading)和函数重写(function overriding)之间有什么区别?
函数重写是一个与多态相关的概念。如果你在基类中声明了一个具有特定实现的虚函数,在派生类中,如果函数签名相同,你可以重写它的行为。如果基类中没有virtual
关键字,派生类中仍然可以创建一个具有相同签名的函数,但这并不构成重写。自C++11起,还有override
说明符,它可以帮助你确保不会错误地未能重写基类函数。你可以在这里找到更多详细信息¹⁷⁸ 。
函数重写使你能够为已经在基类中定义的函数提供特定的实现。
另一方面,函数重载与多态无关。当你有两个函数,它们名字相同、返回类型相同,但参数的数量或类型,或者限定符不同时,就构成了函数重载。
下面是一个基于参数的函数重载示例:
void myFunction(int a);
void myFunction(double a);
void myFunction(int a, int b);
2
3
下面是一个基于限定符的示例:
class MyClass {
public:
void doSomething() const ;
void doSomething();
};
2
3
4
5
实际上,还可以根据函数所属对象是左值引用还是右值引用来重载函数。
class MyClass {
public:
// . . .
void doSomething() &;
void doSomething() &&;
};
2
3
4
5
6
你可以在这里了解更多相关内容¹⁷⁹ 。
# 问题81:override
关键字是什么,它有哪些优点?
override
说明符会告诉编译器和代码阅读者,使用该说明符的函数实际上是在重写其基类中的一个方法。
它告诉阅读者:“这是一个虚方法,重写了基类中的一个虚方法”。
正确使用它时,可能看不到明显效果:
class Base {
virtual void foo();
};
class Derived : Base {
void foo() override ;
};
2
3
4
5
6
7
但它有助于发现const
属性相关的问题:
class Base {
virtual void foo();
void bar();
};
class Derived : Base {
void foo() const override ;
};
2
3
4
5
6
7
8
别忘了,在C++中,方法默认是非虚的。如果使用override
,我们可能会发现没有可重写的方法。如果不使用override
说明符,我们可能只是简单地创建了一个全新的方法。如果你始终使用override
说明符,就不会忘记将基类方法声明为虚函数。
class Base {
void foo();
};
class Derived : Base {
void foo() override ;
};
2
3
4
5
6
7
我们还应该记住,无论是否使用override
说明符,在重写方法时,都不允许进行类型转换:
class Base {
public:
virtual long foo(long x) = 0;
};
class Derived : public Base {
public:
// error: 'long int Derived::foo(int)' marked override,
// but does not override
long foo(int x) override {
// . . .
}
};
2
3
4
5
6
7
8
9
10
11
12
13
在我看来,从C++11开始使用override
说明符是编写整洁代码原则的一部分。它揭示了代码作者的意图,使代码更具可读性,并有助于在构建时发现错误。放心大胆地使用它吧!
# 问题82:解释什么是友元类(friend class)或友元函数(friend function)
有时候,我们需要允许某个特定的类访问我们类的私有(private)或受保护(protected)成员。但要是全局放宽该成员的访问级别,那就太过分了。
除非你能调整设计来消除这种需求,否则解决方案就是使用友元类。友元类能够访问将其声明为友元的类的受保护成员和私有成员。
#include <iostream>
class A {
public:
void foo() {
std::cout << "foo\n";
}
private:
void bar() {
std::cout << "bar\n";
}
friend class B;
};
class B {
public:
void doIt() {
A a;
a.foo();
a.bar();
}
};
int main() {
A a;
a.foo();
// a.bar();
// 这会编译失败,因为A::bar是私有的
B b;
b.doIt();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
与友元类类似,友元函数也能够访问类的私有和受保护成员。友元函数可以是一个自由函数(free function),也可以是一个类的成员函数。
#include <iostream>
class A {
public:
void foo() {
std::cout << "foo\n";
}
private:
void bar() {
std::cout << "bar\n";
}
friend void freeFunction();
};
void freeFunction() {
A a;
a.foo();
a.bar();
}
int main() {
A a;
a.foo();
// a.bar();
freeFunction();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
关于友元类和友元函数的一些要点:
- 友元关系不会被继承。
- 友元关系不是相互的,也就是说,如果某个名为
Friend
的类是另一个名为NotAFriend
的类的友元,那么NotAFriend
并不会自动成为Friend
类的友元。 - 程序中友元类和友元函数的总数应该受到限制,因为过多的友元类和友元函数可能会削弱不同类的封装概念,而封装是面向对象编程固有的、值得追求的特性。
- 友元类和友元函数的一个常见用例是单元测试(unit-testing)。测试类会成为被测试类的友元。
# 问题83:什么是默认参数(default arguments)?在C++函数中它们是如何求值的?
默认参数是在声明函数时赋给参数的值。
默认参数允许在调用函数时不提供一个或多个尾部参数。
参数的默认值在函数声明时的参数列表中指定。我们只需在函数声明中给参数赋值即可。
以下是一个例子:
int calculateArea(int a, int b);
int calculateArea(int a, int b=2);
int calculateArea(int a=3, int b=2);
2
3
当调用函数时,如果一个或多个参数留空,就会根据留空参数的数量使用这些默认值。
让我们看一个完整的例子。如果在函数调用时没有为任何参数传递值,那么编译器会使用提供的默认值。如果指定了一个值,那么默认值将被覆盖,使用传递的值。
#include <iostream>
int calculateArea(int a=3, int b=2) {
return a*b;
}
int main() {
std::cout << calculateArea(6, 4) << '\n';
std::cout << calculateArea(6) << '\n';
std::cout << calculateArea() << '\n';
}
2
3
4
5
6
7
8
9
10
11
如上述代码所示,对calculateArea
函数进行了三次调用。在第一次调用中,我们传递了两个参数,所以默认值被调用者覆盖;在第二次调用中,只传递了一个参数,所以最后一个参数使用默认值。在最后一次调用中,我们没有提供任何参数,所以两个参数都使用默认值。
在函数声明中,在有默认参数的参数之后,所有后续参数都必须在本次声明或同一作用域的先前声明中提供默认参数……除非该参数是从参数包(parameter pack)展开的,并且要记住省略号(...
)不是一个参数。
这意味着int calculateArea(int a=5, int b);
无法编译。另一方面,int g(int n = 0, ...)
可以编译,因为省略号不算作参数。
默认参数只允许在函数声明和lambda表达式(自C++14起)的参数列表中使用,不允许在函数指针声明、函数引用声明或类型定义(typedef)声明中使用。
最后要注意的一点是,你应该始终在头文件中声明默认参数,而不是在.cpp
文件中。
# 问题84:什么是this
指针?我们可以删除它吗?
this
指针是一个常量指针,它保存了当前对象的内存地址。
每当我们通过对象调用成员函数时,编译器会秘密地将调用对象的地址作为第一个参数传递。因此,在所有非静态函数的函数体中,this
作为一个局部变量可用。在静态成员函数中,this
指针不可用,因为静态成员函数可以在不使用任何对象的情况下调用(仅使用类名,如MyClass::foo()
)。
理想情况下,不应该对this
指针使用delete
运算符。然而,如果使用了,那么你必须考虑以下几点。
delete
运算符只适用于在堆上分配的对象(使用new
运算符分配),如果对象是在栈上分配的,其行为是未定义的,应用程序可能会崩溃。
class A {
public:
void fun() {
delete this ;
}
};
int main() {
A *aPtr = new A;
aPtr->fun();
aPtr = nullptr ;
A a;
a.fun();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- 一旦执行了
delete this
,在删除之后不应再访问已删除对象的任何成员。
#include <iostream>
class A {
public:
void fun() {
delete this ;
std::cout << x << '\n';
}
private:
int x{0};
};
int main() {
A a;
a.fun();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
最好根本不要使用delete this
,因为在删除之后很容易意外访问成员变量。此外,调用者可能没有意识到你的对象已经自我销毁了。
而且,delete this
是一种“代码坏味道”,表明你的代码在对象所有权(谁分配以及谁删除)方面可能没有一个合理的策略。
即便如此,delete this
通常出现在引用计数类中,当引用计数减为0时,DecrementRefCount()
/Release()
或其他类似的成员函数会调用delete this
。
# 问题85:C++中的虚继承(virtual inheritance)是什么,何时应该使用它?
虚继承是一种C++技术,它确保孙子派生类(grandchild derived classes)仅继承基类成员变量的一份副本。如果不使用虚继承,假设类B和类C都继承自类A,而类D又同时继承自类B和类C,那么类D将包含类A成员变量的两份副本:一份通过类B继承,另一份通过类C继承。可以使用作用域解析运算符分别访问这两份副本。
相反,如果类B和类C都虚继承自类A,那么类D的对象将只包含类A成员变量的一组副本。
你可能已经猜到,当需要处理多重继承(multiple inheritance),并且要解决臭名昭著的菱形继承(diamond inheritance)问题时,这项技术就很有用。
在实际应用中,当从虚基类派生的类,尤其是虚基类本身是纯抽象类(pure abstract classes)时,虚基类最为适用。这意味着在“交汇类”(位于菱形底部的类)之上的类,如果有数据的话,也非常少。
考虑下面这个类继承体系,它展示了菱形问题,但使用的并非纯抽象类。
struct Person {
virtual ~Person() = default;
virtual void speak() {}
};
struct Student : Person {
virtual void learn() {}
};
struct Worker : Person {
virtual void work() {}
};
// 助教既是学生也是工人
struct TeachingAssistant : Student, Worker {};
TeachingAssistant ta;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
按照上述定义,调用ta.speak()
会出现二义性,因为TeachingAssistant
中有两个间接的Person
基类,所以任何TeachingAssistant
对象都有两个不同的Person
基类子对象。因此,尝试直接将引用绑定到TeachingAssistant
对象的Person
子对象会失败,因为这种绑定本质上是有二义性的:
TeachingAssistant ta;
Person& a = ta; // 错误:TeachingAssistant应该转换为哪个Person子对象,
// 是Student::Person还是Worker::Person?
2
3
为了消除歧义,必须将ta
显式转换为其中一个基类子对象:
TeachingAssistant ta;
Person& student = static_cast<Student&>(ta);
Person& worker = static_cast<Worker&>(ta);
2
3
为了调用speak()
,也需要进行同样的歧义消除或显式限定:static_cast<Student&>(ta).speak()
或static_cast<Worker&>(ta).speak()
,或者也可以用ta.Student::speak()
和ta.Worker::speak()
。显式限定不仅对指针和对象都采用了更简单、统一的语法,还支持静态分派(static dispatch),所以可以说它是更可取的方法 。
在这种情况下,对Person
的多重继承可能并非我们想要的,因为我们希望建模的关系是“助教是一个人”,这种关系只存在一次;助教既是学生又是工人,并不意味着助教是两个人(除非助教精神分裂):Person
基类对应着助教要实现的一种契约(上述的“是一个”关系实际上意味着“实现了……的要求”),而助教只实现一次Person
契约。
“只实现一次”在现实中的意义是,助教应该只有一种实现speak
的方式,而不是根据从学生视角还是工人视角看待助教,出现两种不同的实现方式。(在第一个代码示例中,我们看到Student
和Worker
都没有重写speak()
,所以两个Person
子对象实际上行为相同,但这只是一种特殊情况,从C++的角度来看并无区别。)
如果像这样在继承中引入virtual
,问题就会消失。
struct Person {
virtual ~Person() = default;
virtual void speak() {}
};
// 两个类虚继承Person
struct Student : virtual Person {
virtual void learn() {}
};
struct Worker : virtual Person {
virtual void work() {}
};
// 助教仍然既是学生又是工人
struct TeachingAssistant : Student, Worker {};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
现在我们可以轻松调用speak()
。
TeachingAssistant::Worker
中的Person
部分现在与TeachingAssistant::Student
中使用的Person
实例相同,也就是说,TeachingAssistant
在其表示中只有一个共享的Person
实例,所以调用TeachingAssistant::speak
不会有歧义。此外,从TeachingAssistant
直接转换为Person
也不再有歧义,因为现在只有一个Person
实例可供TeachingAssistant
转换。
这可以通过虚函数表指针(vtable pointers)来实现。这里不深入细节,对象大小会增加两个指针,但背后只有一个Person
对象,也不存在歧义。
必须在菱形继承结构的中间层使用virtual
关键字。在底层使用它并没有帮助。
你可以在核心准则 (opens new window)中找到更多详细信息 。此处还有其他参考资料 。
# 问题86:我们应该一直使用虚继承吗?如果是,原因是什么?如果不是,又是为什么?
答案显然是否定的,我们不应该一直使用虚继承。根据惯用法,C++的一个关键特性是只对使用的功能付出代价。如果你不需要解决虚继承所处理的问题,就不应该为此付出代价。
虚继承几乎很少有必要。它解决的是我们刚刚讨论过的菱形继承问题。只有在存在多重继承的情况下才会出现这个问题,而如果你能避免多重继承,就无需解决这个问题。实际上,许多编程语言甚至都没有这个特性。
下面来看看虚继承的主要缺点。
虚继承会在对象初始化和复制时引发问题。由于“最派生”的类负责这些操作,它必须熟悉基类结构的所有细节。因此,类之间会出现更复杂的依赖关系,这会使项目结构变得复杂,并且在重构时,你必须对所有这些类进行一些额外的修改。所有这些都会导致新的错误,并且使代码可读性变差。
类型转换问题也可能引发错误。你可以通过在原本使用static_cast
的地方使用开销较大的dynamic_cast
来部分解决这些问题。不幸的是,dynamic_cast
的速度要慢得多,如果你在代码中频繁使用它,这意味着你的项目架构还有很大的改进空间。
# 问题87:强类型(strong type)是什么,它有什么优点?
强类型通过其名称携带额外信息和特定含义 。虽然你可以在任何地方使用布尔值或字符串,但它们携带含义的唯一方式是通过实例的名称。
强类型的主要优点是可读性和安全性。
看看这个函数签名,你可能觉得没问题:
Car::Car(unit32_t horsepower, unit32_t numberOfDoors,
bool isAutomatic, bool isElectric);
2
它的参数名相对较好,那问题在哪里呢?看看下面这个可能的实例化代码。
auto myCar{Car(96, 4, false, true)};
这是什么意思?天知道……你可能需要花时间查找构造函数并进行思考。有些集成开发环境(IDE)可以帮助你可视化参数名称,就像Python风格的命名参数那样,但你不应该依赖这个功能。
当然,你可以这样命名变量:
constexpr unit32_t horsepower = 96;
constexpr unit32_t numberOfDoors = 4;
constexpr bool isAutomatic = false;
constexpr bool isElectric = false;
auto myCar = Car{horsepower, numberOfDoors,
isAutomatic, isElectric};
2
3
4
5
6
7
现在你能立刻明白每个变量代表什么。虽然你需要往上看几行才能知道具体的值,但一切都一目了然。另一方面,这样做需要自律。你无法强制要求每个人都这样做。当然,你可以成为一名细致的代码审查员,但你不可能发现每一个问题,而且你也不可能一直盯着代码。
强类型可以帮助你!想象一下这样的函数签名:
Car::Car(Horsepower hp, DoorsNumber numberOfDoors,
Transmission transmission, Fuel fuel);
2
那么之前的实例化代码可以写成这样:
auto myCar{Car{Horsepower{98u}}, DoorsNumber{4u},
Transmission::Automatic, Fuel::Gasoline};
2
这个版本比最初那个可读性很差的版本更长、更啰嗦,但比为每个参数都引入命名良好的辅助变量的版本要短得多。
所以,强类型的一个优点是可读性,另一个优点是安全性。使用强类型后,混淆值的可能性大大降低。在前面的例子中,你可能很容易把车门数量和发动机功率弄混,但使用强类型后,这种混淆实际上会导致编译错误。
# 问题88:什么是用户定义字面量(user-defined literals)?
用户定义字面量允许通过定义用户定义的后缀,让整数、浮点数、字符和字符串字面量生成用户定义类型的对象。
用户定义字面量可用于整数、浮点数、字符和字符串类型。
它们可用于转换,例如,你可以编写一个将角度转换为弧度的转换器:
constexpr long double operator"" _deg ( long double deg ) {
return deg * 3.14159265358979323846264L / 180;
}
//...
std::cout << "50 degrees as radians: " << 50.0_deg << '\n';
2
3
4
5
6
但更有趣的可能是,当使用强类型时,它如何有助于提高代码的可读性和安全性。
让我们看看昨天课程中的这个例子:
auto myCar{Car{Horsepower{98u}}, DoorsNumber{4u},
Transmission::Automatic, Fuel::Gasoline};
2
把车门数量和发动机功率弄混的可能性或许不高,但可能存在其他衡量性能的系统,或者Car
构造函数接受的其他数值,那就容易混淆了。
一种可读性强且安全的方式,是通过用户定义字面量,用硬编码值初始化Car
对象(比如在单元测试中):
//...
Horsepower operator"" _hp(int performance) {
return Horsepower{performance};
}
DoorsNumber operator"" _doors(int numberOfDoors) {
return DoorsNumber{numberOfDoors};
}
//...
auto myCar{Car{98_hp, 4_doors,
Transmission::Automatic, Fuel::Gasoline};
2
3
4
5
6
7
8
9
10
11
12
当然,使用用户定义字面量的方式仅受限于你的想象力,但数值转换和辅助强类型无疑是其中最有趣的两种应用。
# 问题89:为什么我们不应该使用布尔型参数?
因为这会降低客户端代码的可读性。假设你有这样一个函数签名:
placeOrder(std::string shareName, bool buy,
int quantity, double price);
2
在这个函数的实现中,可能一切都没问题,在函数里某个地方你可能会这样写:
//...
if (buy) {
// 执行买入操作
//...
} else {
// 执行卖出操作
//...
}
2
3
4
5
6
7
8
这或许不是最理想的写法,但还算可读。
另一方面,从客户端的角度来看:
placeOrder("GOOG", true, 1000, 100.0);
true
到底是什么意思?如果传入false
又代表什么?不查看函数签名根本无从得知。虽然现代的集成开发环境(IDE)可以通过工具提示展示函数签名,甚至还能显示文档(如果有的话),但我们不能把这当作理所当然的,尤其是在C++中,这类工具的水平仍比不上其他流行语言。
而且,即使你去查看了,也很容易忽略或混淆true
和false
。
相反,我们可以使用枚举(enumerations):
enum class BuyOrSell {
Buy, Sell
};
placeOrder("GOOG", BuyOrSell::Buy, 1000, 100.0);
2
3
4
5
使用枚举而不是布尔值会略微增加代码库中的行数(在这个例子中增加了三行),但它对可读性的提升效果要大得多。无论是在API端还是客户端,使用这种技术后,意图都变得非常清晰。
# 问题90:区分浅拷贝(shallow copy)和深拷贝(deep copy)
浅拷贝是将一个对象的内存逐位复制到另一个对象,换句话说,它会复制所有成员字段的值。深拷贝则是逐个字段地从一个对象复制到另一个对象。
二者的重大区别在于,对于那些并非存储值,而是存储指向值的指针的成员,浅拷贝可能无法达到预期效果。也就是说,对于指向动态分配内存的成员。对这些成员进行浅拷贝意味着不会分配新的内存,实际上,如果你通过一个对象修改了这样的成员,所有拷贝都会显示出这些变化 。
默认的拷贝构造函数(copy constructor)和赋值运算符(assignment operator)执行的是浅拷贝。如果一个类有引用或指针成员,那么很有可能你需要自己编写拷贝构造函数,并且也应该编写赋值运算符。
如果要这么做,不要忘记“五法则”。实现其中一个特殊函数(比如刚刚提到的拷贝构造函数或赋值运算符),很可能意味着你需要实现所有其他特殊函数。
总之,默认的拷贝构造函数和默认的赋值运算符执行的是按成员浅拷贝,对于不包含动态分配变量的类来说,这没问题。另一方面,包含动态分配变量的类需要有执行深拷贝的拷贝构造函数和赋值运算符。
# 问题91:类函数是否会被计入对象大小?
不会,只有类成员变量决定相应类对象的大小。一个对象的大小至少是其数据成员大小之和,并且永远不会小于这个和。
那它怎么会更大呢?
编译器可能会在数据成员之间插入填充字节,以确保每个数据成员都满足平台的对齐要求。有些平台对对齐的要求比其他平台更严格,一般来说,具有正确对齐的代码性能会显著提升。因此,优化设置可能会影响对象的大小。
静态成员不属于类对象,因此它们不包含在对象的布局中,也不会增加对象的大小。
虚函数(Virtual functions)和继承给对象大小的计算带来了一些复杂性,我们改天再讨论这个问题。
使用sizeof(myObj)
或sizeof(MyClass)
总能得到对象的正确大小。
# 问题92:动态分派(dynamic dispatch)是什么意思?
在计算机科学中,动态分派是在运行时选择调用多态操作(方法或函数)的具体实现的过程。它通常用于面向对象编程(OOP)语言和系统中,并且被视为其主要特征之一。
动态分派与静态分派(static dispatch)形成对比,在静态分派中,多态操作的实现是在编译时选择的。动态分派的目的是将选择合适实现的操作推迟到知道参数(或多个参数)的运行时类型之后。
动态分派与后期绑定(late binding)或动态绑定(dynamic binding)不同。一个多态操作有多个实现,它们都与同一个名称相关联。绑定可以在编译时进行,也可以(通过后期绑定)在运行时进行。通过动态分派,在运行时选择操作的一个特定实现。虽然动态分派并不意味着后期绑定,但后期绑定意味着动态分派,因为后期绑定操作的实现直到运行时才知道。
在实践中,动态分派通常意味着找到要调用的正确函数。一般情况下,当你在类中定义一个方法时,编译器会记住它的定义,每次遇到对该方法的调用时都会执行它。
#include <iostream>
class A {
public:
virtual void foo() {
std::cout << "This is A::foo()" << '\n';
}
};
class B : public A {
public:
void foo() override {
std::cout << "This is B::foo()" << '\n';
}
};
int main() {
A* a = new B();
a->foo();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
如果我们使用静态分派,调用a->foo()
会执行A::foo()
,因为(从编译器的角度来看)a
指向一个A
类型的对象。但这是错误的,因为a
实际上指向一个B
类型的对象,应该调用B::bar()
。
正是动态分派使我们能够实现这种行为。
# 问题93:什么是虚函数表(vtable)和虚指针(vpointer)?
在继续探讨如何计算对象大小时,我们应该先了解什么是虚函数表和虚指针。
虚函数表和虚指针是实现动态分派的手段。对于每个包含虚函数的类,编译器会构造一个虚函数表(vtable),它作为一个函数查找表,用于以动态绑定的方式解析函数调用。虚函数表为类可访问的每个虚函数都包含一个条目,并存储指向其定义的指针。虚函数表中只存储类可调用的最具体的函数定义。虚函数表中的条目可以指向类本身声明的虚函数,也可以指向从基类继承的虚函数。
虚函数表及其包含的函数指针是在类级别维护的,也就是说,每个类定义一个虚函数表。
每次编译器为一个类创建虚函数表时,都会给它添加一个额外的参数:一个指向相应虚函数表的指针,称为虚指针。虚指针只是编译器添加的另一个类成员。它会使每个包含虚函数表的类的对象大小增加一个虚指针的大小。这意味着虽然每个类有一个虚函数表,但每个对象有一个虚指针。
当调用一个虚函数时,对象的虚指针用于找到该类相应的虚函数表。接着,函数名作为虚函数表的索引,用于找到要执行的最具体的函数。
# 问题94:基类的析构函数(destructors)应该是虚函数吗?
在过去的几天里,我们一直在讨论多态、继承以及相关的内容。所以我们再次讨论析构函数也就不足为奇了。
通常的答案是“是的,当然”,但这并不正确。只要想想标准库本身,很多类都没有虚析构函数。我在关于强类型容器的文章里写过这个内容。所以,如果像std::vector
就没有虚析构函数,那么“当然”这个答案就不可能是正确的。
根据赫伯·萨特(Herb Sutter)的说法,“基类的析构函数要么是公共的虚函数,要么是受保护的非虚函数”。
任何通过基类接口执行,并且应该具有多态行为的操作都应该是虚函数。因此,如果可以通过基类接口以多态方式执行删除操作,那么析构函数必须具有多态行为,也必须是虚函数。这是语言的要求——如果你在没有虚析构函数的情况下以多态方式删除对象,就会面临所谓的未定义行为(undefined behaviour),这是我们始终要避免的情况。
class Base { /*...*/ };
class Derived : public Base { /*...*/ };
int main() {
Base* b = new Derived;
delete b; // Base::~Base()最好是虚函数!
}
2
3
4
5
6
但基类并非总是需要支持多态删除。在这种情况下,将基类析构函数设为受保护的非虚函数,这会使通过基类指针进行的删除操作失败:
class Base {
/*...*/
protected:
~Base() {}
};
class Derived : public Base { /*...*/ };
int main() {
Base* b = new Derived;
delete b; // 错误,不合法
}
2
3
4
5
6
7
8
9
10
11
12
程序报错:
/*
main.cpp: In function 'int main()':
main.cpp:11:10: error: 'Base::~Base()' is protected withi\
n this context
11 | delete b; // error, illegal
| ^
main.cpp:4:3: note: declared protected here
4 | ~Base() {}
| ^
*/
2
3
4
5
6
7
8
9
10
# 问题95:C++中的抽象类(abstract class)是什么?
C++中的抽象类是指至少包含一个纯虚函数(pure virtual function)的类。纯虚函数是在该类中没有实现的函数。
class AbstractClass {
public:
virtual void doIt() = 0;
};
2
3
4
这样的类不能被实例化。如果你尝试声明一个AbstractClass
类型的变量a
,会收到类似“无法将变量‘a’声明为抽象类型‘AbstractClass’,因为在‘AbstractClass’中以下虚函数是纯虚函数:virtual void AbstractClass::doIt()”的错误消息。
任何想要被实例化且继承自抽象类的类,都必须实现所有的纯虚函数(换句话说,就是抽象函数)。不过,从抽象类继承而来但仅作为其他类的基类的非叶类(non-leaf classes),不必实现纯虚函数:
class AbstractClass {
public:
virtual void doIt() = 0;
};
class StillAbstractClass : public AbstractClass {
public:
// 无需实现doIt,我们甚至可以添加更多纯虚函数
virtual void foo() = 0;
};
class Leaf : public StillAbstractClass {
void doIt() override { /*... */ }
void foo() override {/*... */ }
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
虽然我们不能实例化一个抽象类,但可以有指向它的指针或对它的引用:
int main() {
StillAbstractClass* s = new Leaf();
}
2
3
一个类只要有至少一个纯虚函数就是抽象类。所以,抽象类可以有非虚函数或非纯虚函数,甚至可以有构造函数和一些成员。
# 问题96:是否有可能在不使用虚函数(virtual functions)的情况下实现多态行为(polymorphic behaviour)?
简而言之,答案是肯定的。怎么做呢?
这是个有趣的问题!通过从一个将继承类作为模板参数的类继承来实现。这被称为“奇异递归模板模式”(Curiously Recurring Template Pattern)或“倒置继承”(Upside Down Inheritance)。在微软工作时,克里斯蒂安·博蒙特(Christian Beaumont)在一次代码审查中看到这种用法时,认为它不可能编译通过,但实际上它可行,并且在一些Windows库中被广泛使用。
它的一般形式是:
// 奇异递归模板模式(CRTP)
template <class T>
class Base {
// Base中的方法可以使用模板访问Derived的成员
};
class Derived : public Base<Derived> {
//...
};
2
3
4
5
6
7
8
9
这样做的目的是在基类中使用派生类。从基类对象的角度看,派生类对象就是它自己,只是进行了向下转型(downcasted)。因此,基类可以通过将自身静态转换(static_cast)为派生类来访问派生类。
template <typename T>
class Base {
public:
void doSomething() {
T& derived = static_cast<T&>(*this);
// 使用derived...
}
};
2
3
4
5
6
7
8
注意,与通常向派生类的转型不同,这里我们不使用dynamic_cast
。当你想在运行时确保转型到的派生类是正确的类型时,才会使用dynamic_cast
。但在这里我们不需要这种保证:基类被设计为由其模板参数继承,不会被其他类继承。因此,基类将此视为一个假设,使用static_cast
就足够了。
# 问题97:如何使用奇异递归模板模式(CRTP)为类添加功能?
奇异递归模板模式包括:
- 从一个模板类继承
- 将派生类本身作为基类的模板参数
如前所述,这项技术的主要优点是基类可以访问派生类的方法。为什么呢?
因为基类将派生类用作模板参数。
在基类中,我们可以通过静态转换获取底层的派生类对象:
class Base {
void foo() {
X& underlying = static_cast<X&>(*this);
// 现在你可以访问X的公共接口
}
};
2
3
4
5
6
在实践中,这使我们有可能通过一些基类丰富派生类的接口。在实际应用中,你可以使用这项技术为类添加一些通用功能,比如为传感器类添加一些数学函数(如乔纳森·巴卡拉(Johnathan Baccara)所解释的那样 )。虽然这些功能可以实现为非成员函数或非成员模板函数,但在查看类的接口时,很难了解到这些函数。而CRTP基类的公共方法是接口的一部分。
下面是完整的示例:
template <typename T>
struct NumericalFunctions {
void scale(double multiplicator);
void square();
void setToOpposite();
};
class Sensitivity : public NumericalFunctions<Sensitivity> {
public:
double getValue() const;
void setValue(double value);
// Sensitivity丰富接口的其余部分...
};
template <typename T>
struct NumericalFunctions {
void scale(double multiplicator) {
T& underlying = static_cast<T&>(*this);
underlying.setValue(underlying.getValue() * multiplicator);
}
void square() {
T& underlying = static_cast<T&>(*this);
underlying.setValue(underlying.getValue() * underlying.getValue());
}
void setToOpposite() {
scale(-1);
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
你可以查看以下链接,了解更多实际应用。
# 问题98:使用 init() 函数初始化对象有什么合理的理由吗?
没有合理的理由。
当创建一个对象时,用户理应期望得到一个完全可用、完全初始化的对象。如果用户仍然需要调用init()
函数,那就说明并非如此。
相反,当用户停止使用对象时,不应由用户调用destroy()
、close()
或类似的函数。对象应该负责释放其在初始化过程中获取的任何资源。这个概念也被称为RAII(资源获取即初始化,Resource Acquisition Is Initialization)。
如果一个对象确实无法通过构造函数以方便的方式进行实例化,那么构造和初始化过程应该由一个工厂函数(factory function)封装起来,这样用户仍然无需操心这个过程。
总之,不要使用init()
函数。对象应该通过其构造函数和析构函数来管理自身。如果无法通过构造函数完成构造,就用工厂函数封装起来。