第三章 多态、继承和虚函数
# 第三章 多态、继承和虚函数
接下来的十六个问题,我们将聚焦于多态、继承、虚函数以及相关主题。
# 问题13:函数重载和函数重写有什么区别?
函数重写(Function overriding)是一个与多态相关的术语。如果你在基类中声明了一个具有特定实现的虚函数,在派生类中可以使用完全相同的函数签名来重写其行为。如果基类中没有virtual
关键字,派生类中仍可以创建一个具有相同签名的函数,但这不算重写。自C++11起,还有override
说明符,它能帮助你确保没有错误地未能重写基类函数。你可以在此处找到更多详细信息。
函数重写使你能够在派生类中为基类已定义的函数提供特定的实现。
另一方面,函数重载(overloading)与多态无关。当有两个函数名称相同、返回类型相同,但参数数量或类型,或者限定符不同时,就构成了函数重载。
以下是基于参数的函数重载示例:
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
甚至还可以基于实际实例是左值(lvalue)还是右值(rvalue)来重载函数:
class MyClass {
public:
// ...
void doSomething() &; // 当*this是左值时使用
void doSomething() &&; // 当*this是右值时使用
};
2
3
4
5
6
# 问题14:什么是虚函数?
虚函数用于替换,换句话说,重写基类提供的实现。
只要对象实际上是派生类的对象,即使是通过基类指针而不是派生类指针来访问该对象,也总是会调用派生类中的代码。
#include <iostream>
class Car {
public:
virtual void printName() {
std::cout << "Car\n";
}
};
class SUV : public Car {
public:
virtual void printName() override {
std::cout << "SUV\n";
}
};
int main() {
Car* car = new SUV();
car->printName();
}
// 输出:SUV
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
虚函数是基类中存在的成员函数,可能会被派生类重新定义。当我们在基类和派生类中使用相同的函数名时,基类中的函数必须用virtual
关键字声明,否则它不会被重写,只是被隐藏了。
当函数被声明为虚函数时,C++会在运行时根据基类指针所指向的对象类型来确定调用哪个函数。因此,通过让基类指针指向不同的对象,我们可以执行虚函数的不同版本。
虚函数的一些规则:
- 它们总是成员函数。
- 它们不能是静态函数。
- 它们可以是另一个类的友元。
- C++中不存在虚构造函数,但可以有虚析构函数。
实际上,如果你希望其他类从某个给定类继承,应该始终将析构函数声明为虚函数,否则很容易出现未定义行为。
# 问题15:override关键字是什么,它有什么优点?
override
说明符会告知编译器和代码阅读者,使用该说明符的函数实际上是在重写其基类中的一个方法。
它向代码阅读者表明:“这是一个虚方法,重写了基类的虚方法。”
正确使用它时,可能看不出什么效果:
class Base {
virtual void foo();
};
class Derived : Base {
void foo() override; // 没问题:Derived::foo重写了Base::foo
};
2
3
4
5
6
7
但它能帮助你发现有关常量性的问题:
class Base {
virtual void foo();
void bar();
};
class Derived : Base {
void foo() const override; // 错误:Derived::foo没有重写Base::foo
// 它试图重写Base::foo const,但该函数不存在
};
2
3
4
5
6
7
8
9
别忘了,在C++中,方法默认不是虚函数。如果使用override
,可能会发现没有可重写的内容。如果没有override
说明符,我们只是简单地创建了一个全新的方法。如果你始终使用override
说明符,就不会忘记将基类方法声明为虚函数。
class Base {
void foo();
};
class Derived : Base {
void foo() override; // 错误:Base::foo不是虚函数
};
2
3
4
5
6
7
我们还应该记住,无论是否使用override
说明符,重写方法时都不允许进行类型转换:
class Base {
public:
virtual long foo(long x) = 0;
};
class Derived : public Base {
public:
// 错误:'long int Derived::foo(int)'标记为override,但没有重写
long foo(int x) override {
// ...
}
};
2
3
4
5
6
7
8
9
10
11
12
在我看来,使用C++11中的override
说明符是代码整洁原则的一部分。它揭示了作者的意图,使代码更易读,并有助于在构建时发现错误。请毫不犹豫地使用它!
# 问题16:解释协变返回类型的概念,并举例说明其适用场景。
对于虚函数及其所有重写版本使用协变返回类型(covariant return types),意味着你可以用更窄的类型,换句话说,用更具体的类型来替换原始返回类型。
假设你有一个汽车工厂生产线(CarFactoryLine)生产汽车(Car)。这些工厂生产线的特定类型可能生产SUV、跑车(SportsCars)等。如何在代码中表示呢?一种常见的方式是仍然将返回类型设为汽车指针。
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*
需要进行动态类型转换:
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++中,在派生类的重写函数中,你不必返回与基类相同的类型,但可以返回协变返回类型。换句话说,你可以用一个 “更窄” 的类型,也就是更具体的数据类型来替换原始类型。
# 问题17:C++中的虚继承(virtual inheritance)是什么,应该在何时使用它?
虚继承是一种C++技术,它确保孙子派生类仅继承基类成员变量的一份副本。如果不使用虚继承,若类B
和类C
都继承自类A
,而类D
又继承自B
和C
,那么D
将包含两份A
的成员变量:一份通过B
继承,另一份通过C
继承。通过作用域解析,这两份成员变量可以被独立访问。
相反,如果类B
和类C
虚拟继承自类A
,那么类D
的对象将仅包含一组来自类A
的成员变量。
你可能已经猜到,当需要处理多重继承并解决臭名昭著的菱形继承问题时,这项技术就派上用场了。
在实际应用中,当从虚基类派生的类,尤其是虚基类本身是纯抽象类时,虚基类最为适用。这意味着 “连接类”(位于继承层次底部的类)之上的类即使有数据也很少。
考虑下面这个用来表示菱形继承问题的类层次结构,不过这里没有使用纯抽象类。
struct Person {
virtual ~Person() = default;
virtual void speak() {}
};
struct Student : Person {
virtual void learn() {}
};
struct Worker : Person {
virtual void work() {}
};
// 助教既是学生也是工作人员
struct TeachingAssistant : Student, Worker {};
int main() {
TeachingAssistant aTeachingAssistant;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
按照上述定义,调用aTeachingAssistant.speak()
会出现歧义,因为TeachingAssistant
中有两个间接的Person
基类,所以任何TeachingAssistant
对象都有两个不同的Person
基类子对象。因此,试图直接将引用绑定到TeachingAssistant
对象的Person
子对象会失败,因为这种绑定本质上是不明确的:
TeachingAssistant aTeachingAssistant;
Person& aPerson = aTeachingAssistant; // 错误:应该将TeachingAssistant转换为哪个Person子对象,是Student::Person还是Worker::Person?
2
为了消除歧义,必须将aTeachingAssistant
显式转换为其中一个基类子对象:
TeachingAssistant aTeachingAssistant;
Person& student = static_cast<Student&>(aTeachingAssistant);
Person& worker = static_cast<Worker&>(aTeachingAssistant);
2
3
为了调用speak()
,同样需要进行这样的歧义消除或显式限定:
static_cast<Student&>(aTeachingAssistant).speak()
或者
static_cast<Worker&>(aTeachingAssistant).speak()
也可以用
aTeachingAssistant.Student::speak()
或者
aTeachingAssistant.Worker::speak()
显式限定不仅对指针和对象都使用了更简单、统一的语法,还允许静态分派,因此可以说这是更可取的方法。
在这种情况下,Person
的双重继承可能并非我们想要的,因为我们希望建模的关系是 “助教是一个人” 只存在一次;助教是学生且是工作人员,并不意味着他是两次 “人”(除非助教有精神分裂症):Person
基类对应一份契约,TeachingAssistant
实现了这份契约(上述 “是一个” 关系实际上意味着 “实现了…… 的要求”),而TeachingAssistant
只实现一次Person
契约。
“只实现一次” 在现实世界中的意义是,TeachingAssistant
应该只有一种实现speak
的方式,而不是根据TeachingAssistant
的Student
视角或Worker
视角有两种不同的实现方式。(在第一个代码示例中,我们看到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
实例,所以从TeachingAssistant
到Person
的直接转换也不再有歧义。
这可以通过虚函数表指针(vtable pointers)来实现。这里不深入细节,使用虚继承后对象大小会增加两个指针,但背后只有一个Person
对象,并且不会有歧义。
必须在菱形继承结构的中间层使用virtual
关键字。在底部使用它没有帮助。
# 问题18:我们应该一直使用虚继承吗?如果是,为什么?如果不是,又是为什么?
答案显然是否定的,我们不应该总是使用虚继承。按照一种惯常的说法,C++的关键特性之一是只应为使用的功能付出代价。如果你不需要解决虚继承所针对的问题,那就不应该使用它。
虚继承几乎很少有必要。它解决的是我们刚刚提到的菱形继承问题。只有在存在多重继承的情况下才会出现这个问题,而如果你能避免多重继承,那就无需解决这个问题。实际上,许多语言甚至都没有这个特性。
那么,让我们来看看虚继承的主要缺点。
虚继承会给对象初始化和复制带来麻烦。由于 “最派生” 的类负责这些操作,它必须熟知基类结构的所有细节。因此,类之间会出现更复杂的依赖关系,这使得项目结构更加复杂,并且在重构时,你不得不在所有这些类中进行一些额外的修改。所有这些都会导致新的错误,并且使代码可读性变差。
类型转换问题也可能导致错误。你可以通过在原本使用static_cast
的地方使用开销较大的dynamic_cast
来部分解决这些问题。不幸的是,dynamic_cast
的速度要慢得多,如果你在代码中频繁使用它,这意味着你的项目架构还有很大的改进空间。
# 问题19:下面这个示例程序的输出是什么?这是你预期的结果吗?为什么是或为什么不是?
#include <iostream>
#include <memory>
class Animal {
public:
~Animal() = default;
virtual void eat(int quantity) {
std::cout << "Animal eats " << quantity << std::endl;
}
void speak() {
std::cout << "Animal speaks" << std::endl;
}
};
class Dog : public Animal {
public:
void eat(unsigned int quantity) {
std::cout << "Dog eats " << quantity << std::endl;
}
void speak() {
std::cout << "Dog speaks" << std::endl;
}
};
int main() {
Dog d;
d.speak();
d.eat(42u);
std::unique_ptr<Animal> a = std::make_unique<Dog>();
a->speak();
a->eat(42u);
}
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
33
34
35
如果你把上面这段程序输入到编译器中,很容易得到答案。我希望你首先尝试自己思考一下。
Dog speaks
Dog eats 42
Animal speaks
Animal eats 42
2
3
4
如果这是你预期的结果,恭喜!很可能你也知道原因。
如果你没有想出来,并且对上面的结果感到惊讶,也不用担心。再检查一下代码,接着往下读。
原始代码中有两个很容易忽略的小错误,在C++11之前,你只能依靠自己或代码审查人员来发现这些错误。
但从C++11开始,有了override
说明符。它可以帮助你显式标记派生类中的某个函数是重写自基类的函数。
std::unique_ptr<Animal> a = std::make_unique<Dog>();
a->speak();
2
当我们编写这样的代码时,期望a
调用派生类Dog
中定义的行为。然而实际执行的却是Animal
类中的行为。
由于Dog
类的方法签名中缺少override
,让我们加上它,然后重新编译。
#include <iostream>
#include <memory>
class Animal {
public:
~Animal() = default;
virtual void eat(int quantity) {
std::cout << "Animal eats " << quantity << std::endl;
}
void speak() {
std::cout << "Animal speaks" << std::endl;
}
};
class Dog : public Animal {
public:
void eat(unsigned int quantity) override {
std::cout << "Dog eats " << quantity << std::endl;
}
void speak() override {
std::cout << "Dog speaks" << std::endl;
}
};
int main() {
Dog d;
d.speak();
d.eat(42u);
std::unique_ptr<Animal> a = std::make_unique<Dog>();
a->speak();
a->eat(42u);
}
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
33
34
35
根据错误信息,Dog::eat
和Dog::speak
都没有重写任何函数。
在第一种情况下,这是因为基类中的参数是简单的有符号整数int
,而派生类中有一个同名、返回类型相同但参数不同的函数,参数为无符号整数unsigned int
。如果我们想要实现多态行为,就必须让它们匹配。
在另一种情况下,我们只是忘记在基类的函数签名中添加virtual
关键字,因此该函数无法被重写。
修正这两个错误后,就能得到我们通常期望的行为。
#include <iostream>
#include <memory>
class Animal {
public:
~Animal() = default;
virtual void eat(int quantity) {
std::cout << "Animal eats " << quantity << std::endl;
}
virtual void speak() {
std::cout << "Animal speaks" << std::endl;
}
};
class Dog : public Animal {
public:
void eat(int quantity) override {
std::cout << "Dog eats " << quantity << std::endl;
}
void speak() override {
std::cout << "Dog speaks" << std::endl;
}
};
int main() {
Dog d;
d.speak();
d.eat(42u);
std::unique_ptr<Animal> a = std::make_unique<Dog>();
a->speak();
a->eat(42u);
}
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
33
34
35
这里得到的经验是,对于想要重写基类函数的函数,应该始终使用override
说明符,这样就能在编译时捕获这些不易察觉的错误。
# 问题20:在私有继承(private inheritance)的情况下,能否访问基类的公有(public)和受保护(protected)成员及函数?
继承的访问说明符不会影响实现的继承。实现总是基于函数的访问级别进行继承。继承的访问说明符仅影响类接口的可访问性。
这意味着即使使用私有继承,派生类仍可使用基类的所有公有和受保护变量及函数。
另一方面,基类的这些公有和受保护元素无法通过派生类从外部访问。
如果基类的子类(即孙类)的父类是从该基类私有继承的,那么这个孙类将无法访问基类的成员和函数,即使这些成员和函数原本是受保护的甚至是公有的。
#include <iostream>
class Base {
public:
Base() = default;
virtual ~Base() = default;
virtual int x() {
std::cout << "Base::x()\n";
return 41;
}
protected:
virtual int y() {
std::cout << "Base::y()\n";
return 42;
}
};
class Derived : private Base {
public:
int x() override {
std::cout << "Derived::x()\n";
return Base::y();
}
};
class SoDerived : public Derived {
public:
int x() override {
std::cout << "SoDerived::x()\n";
return Base::y();
}
};
int main() {
SoDerived* p = new SoDerived();
std::cout << p->x() << std::endl;
}
/*
main.cpp: 在成员函数‘virtual int SoDerived::x()’中:
main.cpp:31:12: 错误: ‘class Base Base::Base’在此上下文中是私有的
31 | return Base::y();
| ^~~~
main.cpp:19:7: 注意: 在此声明为私有
19 | class Derived : private Base {
| ^~~~~~~
main.cpp:31:19: 错误: ‘Base’不是‘SoDerived’的可访问基类
31 | return Base::y();
| ~~~~~~~^~
*/
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# 问题21:私有继承用于什么场景?
首先,让我们简单回顾一下什么是私有继承。
继承的访问说明符不会影响实现的继承。实现总是基于函数的访问级别进行继承。继承的访问说明符仅影响类接口的可访问性。
这意味着即使使用私有继承,派生类仍可使用基类的所有公有和受保护变量及函数。
另一方面,基类的这些公有和受保护元素无法通过派生类从外部访问。
那么这种情况在什么时候有用呢?
我们可能都学过,继承用于表达 “是一个(is - a)” 的关系,对吧?
如果有Car
类继承自Vehicle
类,我们可以说Car
是一种Vehicle
。然后Roadster
类继承自Car
类,它仍然是一种Vehicle
,可以访问Vehicle
的所有成员(函数)。
但是,如果Vehicle
和Car
之间的继承是私有的会怎样呢?那么那辆闪亮的小红跑车Roadster
将无法访问Vehicle
的接口,即使它在中间是从Car
类公开继承的。
我们不能再简单地称这种关系为 “是一个” 关系了。
这是一种 “有一个(has - a)” 关系。在这个具体例子中,派生类Car
可以访问基类Vehicle
,并根据访问级别(受保护或私有)来暴露它。而后者意味着它不会被暴露,它就像一个私有成员。
在受保护的情况下,你可能会说Roadster
仍然可以访问Vehicle
,这是对的。
但是当使用非公有继承时,你不能把Roadster
当作Vehicle
来创建。下面这行代码将无法编译:
Vehicle* p = new Roadster();
需要再次强调的是,C++中的非公有继承表达的是 “有一个” 关系,就像组合(composition)一样。
所以,如果我们继续以汽车为例,可以说Car
可以私有继承自假设的Engine
类,同时它仍然公开继承自Vehicle
类。通过这个小小的多重继承示例,你可能已经明白了为什么组合比私有继承更容易维护。
但即使你不打算引入继承树,我认为私有继承也不直观,它与大多数其他语言差异很大,使用起来会让人感到困惑。它本身并没有错,只是维护成本更高。
这正是你可以在C++核心准则中找到的内容:尽可能使用组合,必要时才使用私有继承。
但是什么时候是必要的呢?
根据核心准则,当满足以下条件时,就有合理的使用场景:
- 派生类必须调用基类的(非虚)函数。
- 基类必须调用派生类的(通常是纯虚)函数。
# 问题22:能否从构造函数或析构函数中调用虚函数?
从技术上讲,你可以这样做,代码也能编译通过。但这样的代码可能会产生误导,甚至可能导致未定义行为。
在构造基类的过程中尝试调用派生类的函数是很危险的。
#include <iostream>
class Base {
public:
Base() {
foo();
}
protected:
virtual void foo() {
std::cout << "Base::foo\n";
}
};
class Derived : public Base {
public:
Derived() {}
void foo() override {
std::cout << "Derived::foo\n";
}
};
int main() {
Derived d;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
输出结果只是Base::foo
。
- 根据约定,派生类构造函数首先会调用基类构造函数。
- 基类构造函数调用的是基类的成员函数,而不是子类中重写的函数,这会让子类的开发者感到困惑。
如果虚函数也是纯虚函数,就会出现未定义行为。运气好的话,在链接时你会得到一些错误提示。
有什么解决办法呢?
最简单的方法可能就是完全指定你要调用的函数:
Base() {
Base::foo();
}
2
3
从基类中,你只能调用基类的函数。当你在派生类中时,你可以决定调用哪个类的版本。
一种更优雅的解决方案是将虚函数封装在非虚函数中,并且在构造函数 / 析构函数中只使用非虚包装函数。
# 问题23:虚析构函数(virtual destructor)有什么作用?
当对象超出作用域时,会执行其析构函数。析构函数不接受参数,不能被重载,但可以是虚函数。
使用虚析构函数,你可以在不知道对象类型的情况下销毁对象,对象会通过虚函数机制调用正确的析构函数。对于抽象类,析构函数也可以声明为纯虚函数。
派生类的析构函数(无论你是否显式定义)会自动调用基类子对象的析构函数。基类在成员对象之后被析构。在多重继承的情况下,直接基类会按照它们在继承列表中出现顺序的相反顺序被析构。
但我们仍然不知道为什么需要将析构函数声明为虚函数。
这是因为如果你通过指向基类的指针删除派生类的对象,而该基类的析构函数不是虚函数,那么行为是未定义的。相反,基类的析构函数应该声明为虚函数。
将基类析构函数声明为虚函数可确保派生类对象被正确析构,即基类和派生类的析构函数都会被调用。作为一个指导原则,只要类中有虚函数,就应该立即添加一个虚析构函数(即使它什么也不做)。这样可以避免日后出现意外情况。
# 问题24:我们可以从标准容器(如std::vector
)继承吗?如果可以,会有什么影响?
标准容器将它们的构造函数声明为公有且非最终的,所以从它们继承是可行的。事实上,这是一种广为人知且常用的技术,用于从强类型容器中获益。
class Squad : public std::vector {
using std::vector::vector;
// ...
};
2
3
4
这种做法很简单,也具有可读性,但你会在不同的论坛上发现很多人说这是第八大 “致命罪过”,如果你是一名严谨的开发者,就应该不惜一切代价避免这样做。
他们为什么这么说呢?
主要有两个理由。一是在STL中,算法和容器是被明确区分开来的关注点。二是关于缺少虚析构函数的问题。
但这些真的是合理的担忧吗?也许是,这取决于具体情况。
让我们从缺少虚析构函数这个问题开始分析。这似乎更实际一些。
确实,缺少虚析构函数可能会导致未定义行为和内存泄漏。这两个问题都很严重,但未定义行为更糟糕,因为它不仅可能导致程序崩溃,甚至可能导致难以检测的内存损坏,最终导致应用程序出现奇怪的行为。
但缺少虚析构函数并不会默认导致未定义行为和内存泄漏,只有在你特定的使用派生类方式下才会出现。
如果你通过指向基类的指针删除一个对象,而该基类的析构函数不是虚函数,你就必须面对未定义行为的后果。另外,如果派生对象引入了新的成员变量,还会出现内存泄漏问题。不过,这相对来说是个较小的问题。
另一方面,这也意味着那些因为未定义行为和内存泄漏而坚决反对从std::vector
(或任何没有虚析构函数的类)继承的人并不完全正确。
如果你清楚自己在做什么,并且仅使用这种继承来引入强类型向量,而不是为容器引入多态行为和额外状态,那么使用这种技术完全没问题。不过你必须要清楚其中的局限性,尽管在公共库的情况下,这可能不是最佳策略。稍后会详细讨论这一点。
所以另一个主要担忧是,你可能会在新对象中混合使用容器和算法。因为STL的创造者是这么说的,所以这被认为是不好的做法。那又怎样呢?最初设计STL的Alexander Stepanov以及后来为其做出贡献的其他人都是很聪明的人,很有可能他们比我们大多数人都更擅长编程。他们设计的函数和对象在C++社区中被广泛使用,可以说几乎每个人都在使用。
但很可能我们的工作并没有这样的限制,我们不是在为整个C++社区开发东西。我们是在为有特定严格限制的特定应用程序工作。我们的代码不会像这样被复用,永远不会。我们大多数人不是在开发通用库,而是在开发一次性的商业应用程序。
只要我们保持代码的整洁(不管这个 “整洁” 意味着什么),提供一个非通用的解决方案完全没问题。
总之,可以说在应用场景中,为了提供强类型而从容器继承是可行的,只要你不涉及多态。
# 问题25:强类型(strong type)是什么意思,它有什么优点?
强类型通过其名称携带额外信息和特定含义⁵⁴。虽然你可以在任何地方使用布尔值(booleans)或字符串(strings),但它们传递含义的唯一方式是通过变量名。 强类型的主要优点是可读性和安全性。 看看这个函数签名,你可能觉得没问题:
Car::Car(unit32_t horsepower, unit32_t numberOfDoors,
bool isAutomatic, bool isElectric);
2
它的参数名比较合理,那问题出在哪呢?看看可能的实例化代码:
auto myCar{Car(96, 4, false, true)};
这是什么意思?天晓得…… 你可能也不清楚!除非你花时间去查看构造函数并仔细思考。有些集成开发环境(IDEs)可以帮你直观显示参数名,就好像它们是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
这个版本比原来那个可读性很差的版本更长、更啰嗦,但比为每个参数都引入命名恰当的临时变量的版本要短得多。 所以强类型的一个优点是可读性,另一个优点是安全性。使用强类型后,混淆值的情况会大大减少。在前面的例子中,你很容易把车门数量和性能参数弄混,但使用强类型后,这种混淆实际上会导致编译错误。
# 问题26:解释短路求值(short - circuit evaluation)
下面这段代码的输出是什么,为什么?
#include <iostream>
bool a() {
std::cout << "a|";
return false;
}
bool b() {
std::cout << "b|";
return true;
}
int main() {
if (a() && b()) {
std::cout << "main";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
输出将只是a|
。
在main()
函数中,在if
语句里,首先调用子表达式a()
,它会输出a|
。由于a()
的返回值为false
,所以整个表达式不可能为true
,于是第二个子表达式b()
就不会被执行。
在C++和许多其他编程语言中都存在短路求值,也就是说,一旦确定整个条件不可能满足,求值就会停止。
由于这两个子表达式是通过&&
连接的,两边都必须为true
,整个表达式才为true
:
a() | b() | a() && b() |
---|---|---|
false | false | false |
false | true | false |
true | false | false |
true | true | true |
如果a()
不为true
,为了节省宝贵的CPU周期,表达式的其余部分将不再求值。
除了明显的可读性之外,短路求值也是条件中的调用不应有副作用(改变对象/变量的状态)的一个原因。当计算一个布尔表达式时,它应该只返回一个布尔值。调用者无法确定这个表达式是否会被求值。
我知道这听起来很简单,可能我们在刚开始学习C++时就都学过了。但还有一点需要记住。当你为自己的类定义逻辑运算符时,比如重载operator&&
,你就会失去短路求值的特性。
如果有类似myObjectA && myObjectB
这样的表达式,即使第一个操作数myObjectA
求值为false
,两边也都会被求值。
原因是在调用重载的operator&&
之前,重载函数的左操作数和右操作数都会被求值。函数调用是一个序列点,因此在进行函数调用之前,所有的计算和副作用都已经完成。这是一种积极求值策略。
# 问题27:什么是析构函数(destructor),如何对其进行重载?
析构函数是类的一个特殊成员函数。它与类同名,并且前面还带有一个波浪号(~),例如~MyClass
。只要对象超出作用域,若存在析构函数,它就会自动执行。
析构函数没有参数,不能是const
、volatile
或static
的,并且和构造函数一样,它没有返回类型。
默认情况下,析构函数由编译器生成,但你需要注意“五法则”(rule of 5)的应用。如果手动实现了其他4个特殊函数中的任何一个,编译器就不会生成析构函数。快速回顾一下,除析构函数外,特殊函数还有:
- 拷贝构造函数(copy constructor)
- 拷贝赋值运算符(copy assignment operator)
- 移动构造函数(move constructor)
- 移动赋值运算符(move assignment operator)
如果类获取了必须释放的资源,就需要析构函数。记住,你应该编写遵循资源获取即初始化(RAII,Resource Acquisition Is Initialization)原则的类,这意味着在构造时获取资源,在析构时释放资源。这些资源可以是释放连接、关闭文件句柄、保存事务等。
如前所述,析构函数没有参数,不能是const
、volatile
或static
的,并且只能有一个析构函数。因此,它不能被重载。
另一方面,析构函数可以是虚函数(virtual),但这是下一个问题要讨论的内容。
# 问题28:下面这段代码的输出是什么,为什么?
#include <iostream>
class A {
public:
int value() { return 1; }
int value() const { return 2; }
};
int main() {
A a;
const auto b = a.value();
std::cout << b << '\n';
}
2
3
4
5
6
7
8
9
10
11
12
13
输出将是1。如果a
被声明为const
,而b
不是,结果将是2。实际上,结果与b
是否为const
完全无关。
为什么会这样呢?
如果一个函数有两个重载版本,一个是const
版本,另一个不是,编译器会根据对象是否为const
来选择调用哪个版本。
另一方面,这与返回变量的const
属性无关。
这只是对后续内容的一个简单介绍,接下来我们将讨论const
属性、const
正确性以及如何使用const
关键字。
# 问题29:如何在C++中使用= delete
说明符?
这个问题也可以表述为:如何禁止函数调用时的隐式类型转换?
假设有一个处理整数的函数。这里说的是整数,比如它的参数表示一辆汽车能坐多少人。可能是2人,有一些特殊的三座车,豪华车可能坐4人,大多数车能坐5人。不会是4.9人,也不是5.1人,甚至不会是5个半人,就是5人。我们不涉及人体部分(这种不合理的数值)。
那么如何确保函数只接收整数作为参数呢?
显然,你会使用一个整型参数。它可以是int
,甚至unsigned
,或者仅仅是short
,有很多选择。你可能还会在文档中注明numberOfSeats
参数应该是一个整数。
很好!
那么,如果客户端调用时仍然传入一个浮点数会怎样呢?
#include <iostream>
void foo(int numberOfSeats) {
std::cout << "Number of seats: " << numberOfSeats << '\n';
// . . .
}
int main() {
foo(5.6f);
}
/*
Number of seats: 5
*/
2
3
4
5
6
7
8
9
10
11
12
13
浮点型参数被接受并转换为整数。甚至不能说它是四舍五入,它是被隐式转换为整数的。 你可能会说,在某些情况下这没问题。但在其他情况下,这种行为是完全不可接受的。 在这种情况下,如何避免这个问题呢?显然,你可以在调用方进行处理,但:
- 如果
foo
函数经常被使用,每次都进行检查会很繁琐; - 如果
foo
是外部使用的应用程序编程接口(API)的一部分,你无法控制调用方。
从C++11开始,我们可以使用delete
说明符来限制某些类型的拷贝、移动,实际上甚至可以限制其多态使用。
但是= delete
的用途不止于此。它可以应用于任何函数,无论是成员函数还是自由函数。
如果你不想允许从浮点数进行隐式转换,可以简单地删除接受float
类型参数的foo
重载版本:
#include <iostream>
void foo(int numberOfSeats) {
std::cout << "Number of seats: " << numberOfSeats << '\n';
// . . .
}
void foo(double) = delete ;
int main() {
foo(5.6f);
}
/*
main.cpp: In function 'int main()':
main.cpp:10:6: error: use of deleted function 'void foo(d\
ouble)'
10 | foo(5.6f);
| ~~~^~~~~~
main.cpp:8:6: note: declared here
8 | void foo(double) = delete;
| ^~~
*/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
就是这样。通过删除函数的某些重载版本,你可以禁止从某些类型隐式转换为你期望的类型。 现在,你完全可以控制用户可以向你的API传递什么样的参数了。