0%

原文:Message Passing and the Actor Model

简介

自有分布式计算以来,人们就已经讨论过消息传递编程模型了,因此消息传递可以用于表示许多事情。Wikipedia上消息传递的宽泛定义包括了远程过程调用(RPC)和消息传递接口(MPI)等。另外,实践中的消息传递系统也受到了像是pi-calculus和CSP这些流行的进程演算(process-calculus)的启发。例如,Go的channel就是基于pi-calculus中消息通道作为一等公民的思想,而Clojure中的core.async库则是基于CSP。但今天人们谈起消息传递时,通常是指actor模型。作为一种通用的消息传递编程模型,它开端于20世纪70年代,并在今天用于构建大规模可伸缩系统。

在消息传递编程模型领域,重要的不仅是考虑目前的研究现状,还包括历史上最初的关于消息传递和actor模型的论文,它们是更近期的论文描述的编程模型的源头。看看这些模型卡在了哪里,以及近期的论文都引用和指出了旧论文中的哪些不足,这些是很有启发性的。历史上有许多编程语言是围绕着消息传递设计的,特别是那些专注于actor模型和组织计算单元的语言。

本章我会描述actor模型的四个主要变体:经典actor模型、基于进程的actor模型、通信事件循环模型、以及活动对象模型。我会试着强调体现了这些模型的历史上和现代的语言,以及程序员需要注意的编程哲学和取舍,从而理解并最好的利用这些模型。

尽管actor模型早在20世纪70年代就开始了,正如许多近期发表的论文和系统显示,它仍在发展,并被纳入到今天的编程语言中。有些健壮的工业级actor系统正被用于赋能大规模可伸缩分布式系统,例如Akka被用于服务PayPal的十亿级的事务,Erlang被用于为WhatsApp的上亿用户发送消息,而Orleans被用于服务Halo4的数百万玩家。围绕着监控、错误处理、actor生命周期管理,有许多不同方式去构建一个工业级的actor框架,后面会详述。

对于我们介绍的actor模型,一个重点就在这个问题中:“为什么要传递消息,特别是为什么要用actor模型?”考虑到有那么多分布式编程模型,有人可能会问,为什么这个模型在最初提出时这么重要?为什么它促进了当今广泛使用的高级语言、系统和库?我们将在这一章看到,actor模型的一些最明显的优点包括了actor状态的隔离、可伸缩性、以及简化程序员对系统的推理难度。

阅读全文 »

原文:Real-world Concurrency

你可能不需要真的去写多线程的代码。但如果你需要的话,一些关键原则能帮助你掌握这项“魔法”。

软件从业者们可能因为近期微处理器的发展而对软件行业的未来产生恐惧,这种恐惧情有可原。尽管摩尔定律继续存在(晶体管密度依然每18个月翻倍),但因为难以解决的物理限制和实际的工程考虑,新增的晶体管密度不再花在提高时钟频率上了,而是用于在一块CPU晶元上放置多个CPU核心。从软件角度看,这不是一个革命性的变化,而是一个渐进的变化:多核心CPU不是一种新范式,而是将旧范式(多进程)推向更广泛的发展。但根据近期有关这一话题的诸多文章和论文,有人可能会得出这一判断:并发编程的绽放是即将到来的灾难,所谓“免费午餐结束了”。

作为长期处于并发系统第一线的从业者,我们希望为这场总会陷入歇斯底里的争论注入一些现实的冷静(如果不是来之不易的智慧的话)。尤其是,我们希望能回答一个本质的问题:并发性的扩散对你开发的软件意味着什么?也许有点遗憾,这个问题的答案既不简单又不普适——你的软件与并发之间的关系取决于它物理上在哪执行,它在抽象栈中的位置,以及围绕它的经济模型。

考虑到许多软件项目现在都有位于不同抽象层次、跨越不同架构的组件,你可能会发现即使对于你自己写的软件,上面问题的答案也不只一个,而是多个:你可能可以留一些代码永远串行执行,而另一些需要高度并行化且显式多线程执行。再令答案复杂一些,我们会认为你的很多代码不会整整齐齐地属于这两类之一:它可能在本质上是串行的但在某些层次上需要注意并发性。

尽管我们断言需要并行的代码要比某些人恐惧的更少,但必须要承认写并行代码仍然是一种魔法。因此我们也给出了开发高并行度的系统需要的具体实现技术。因此,本文有些二分:我们既认为大多数代码可以(且应该)实现并发,且不需要显式的并行化,同时还要为那些必须写显式并行代码的人解释实现技术。本文半是禁欲的训诫,半是爱经的指导。

阅读全文 »

注:本文非原创,内容来自若干相关文章和回答。

Wikipedia - Mixin

在OOP中,mixin是一种特殊的类,它的方法用于给其它类使用,但它在逻辑上并不是这些类的父类。相比于“被继承”,它更接近于“被包含”。

Mixin鼓励代码重用,且不会有多重继承导致的歧义(菱形问题)。mixin也可以看作是方法有实现的接口,是“依赖反转原则”的一个例子。

mixin可以通过继承来实现。包含功能的mixin类作为父类,而需要它功能的类就继承自它,但这不是接口与实现的关系:子类依然可以继承父类的特性,但不必“is a”父类。

优点:

  • 提供了一种多重继承的机制,允许多个类使用相同的功能,而不会有多重继承的复杂机制。
  • 代码重用性:想在不同类之间共享功能时,mixin很有用。
  • mixin允许只从父类继承一部分功能,而不是全部功能。

js的例子:

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
32
33
34
35
36
const Halfling = function (fName, lName) {
this.firstName = fName;
this.lastName = lName;
};

const mixin = {
fullName() {
return this.firstName + ' ' + this.lastName;
},
rename(first, last) {
this.firstName = first;
this.lastName = last;
return this;
}
};

// An extend function
const extend = (obj, mixin) => {
Object.keys(mixin).forEach(key => obj[key] = mixin[key]);
return obj;
};

const sam = new Halfling('Sam', 'Loawry');
const frodo = new Halfling('Freeda', 'Baggs');

// Mixin the other methods
extend(Halfling.prototype, mixin);

console.log(sam.fullName()); // Sam Loawry
console.log(frodo.fullName()); // Freeda Baggs

sam.rename('Samwise', 'Gamgee');
frodo.rename('Frodo', 'Baggins');

console.log(sam.fullName()); // Samwise Gamgee
console.log(frodo.fullName()); // Frodo Baggins

Stackoverflow - Mixin vs inheritance

mixin通常伴随着多重继承,从这个角度讲它和继承没有区别。

区别在于,mixin很少作为独立对象。泛泛而谈,mixin就是继承,但它的角色与接口继承是不一样的,它是一个基本类或组件,是用来组合的。“mixin”这个名字就暗示着它是用来与其它代码混入的,也意味着mixin类不能独立使用。

mixin类应该是平铺的,线性的,而不应该是树形的。

mixin是(多重)继承的受限的特例,用于继承实现,Ruby等语言允许mixin,但不允许通用概念的多重继承。

mixin与抽象基类(接口)的对比:两者都是不希望被实例化的父类。mixin是提供功能而不直接使用,用户使用子类。抽象基类(接口)是提供接口而不提供功能,用户使用接口。

C++的mixin

链接:

mixin是来自lisp的一个概念,一个解释是:

一个mixin就是类里的一小块,它就是用来与其它类或mixin做组合的。

一个独立的类(如Person)与一个mixin的区别在于,一个mixin只建模小的功能点(如printing或displaying),不是用来独立使用的,而是给其它需要这个功能的类做组合的。

mixin要解的问题:如何建模一系列正交概念。可以用继承来做,每个概念变成一个接口,然后具体类去实现这些接口。

继承的问题是无法自由的组合这些具体类。

mixin是给一组基础类,每个都建模了一个抽象概念,且能直接组合成一个新的类,像乐高一样,满足需求。如果你新增了基础类,只要它是和其它基础类正交的,就可以扩展到这个组合类里。

C++中的一种技术是结合模板和继承,通过模板参数来组合基础类。一个例子:

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
32
33
34
struct Number {
typedef int value_type;
int n;
void set(int v) { n = v; }
int get() const { return n; }
};

template <typename BASE, typename T = typename BASE::value_type>
struct Undoable : public BASE {
typedef T value_type;
T before;
void set(T v) { before = BASE::get(); BASE::set(v); }
void undo() { BASE::set(before); }
};

template <typename BASE, typename T = typename BASE::value_type>
struct Redoable : public BASE {
typedef T value_type;
T after;
void set(T v) { after = v; BASE::set(v); }
void redo() { BASE::set(after); }
};

typedef Redoable< Undoable<Number> > ReUndoableNumber;

int main() {
ReUndoableNumber mynum;
mynum.set(42); mynum.set(84);
cout << mynum.get() << '\n'; // 84
mynum.undo();
cout << mynum.get() << '\n'; // 42
mynum.redo();
cout << mynum.get() << '\n'; // back to 84
}

(另一种方法是通过CRTP来实现,参见这里

MIXINS AS ALTERNATIVE TO INHERITANCE IN JAVA 8

假如有两个类ShipAirport

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Ship {
List<Cargo> cargoes;

public void addCargo(Cargo c) {
cargoes.add(c);
}

public void removeCargo(Cargo c) {
cargoes.remove(c);
}
}

public class Airport {
List<Aircraft> aircrafts;

public void land(Aircraft a) {
aircrafts.add(a);
}

public void depart(Aircraft a) {
aircrafts.remove(a);
}
}

假如我们要写一个新类,既是Ship又是Airport,怎么写?多继承是不可能的了:

1
class AircraftCarrier extends Airport, Ship // doesn't compile

ShipAirport改成接口,再写个实现类?

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
public interface Ship {
void addCargo(Cargo c);
void removeCargo(Cargo c);
}

public interface Airport {
void land(Aircraft a);
void depart(Aircraft a);
}

class AircraftCarrier implements Ship, Airport {
List<Aircraft> aircrafts;
List<Cargo> cargoes;

public void land(Aircraft a) {
aircrafts.add(a);
}

public void depart(Aircraft a) {
aircrafts.remove(a);
}

public void addCargo(Cargo c){
cargoes.add(c);
}

public void removeCargo(Cargo c){
cargoes.remove(c);
}
}

太繁琐,没有能重用代码,有时候还没办法改。

想想Ruby里怎么做?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module Airport  # for simplicity: module is same as class
@aircrafts = Array.new # variable

def land(aircraft)
aircrafts.push(aircraft)
end

def depart(aircraft)
aircraft.delete(aircraft)
end
end


class AircraftCarrier
include Ship
include Airport
end

这里我们只要把ShipAirport结合起来,不需要通过继承。注意这不是多重继承场景。Ruby原生支持mixin。Scala里有类似的特性叫Traits:

1
2
3
4
5
6
7
8
9
10
11
12
13
trait Ship {
val cargoes : ListBuffer[Cargo]

def addCargo(c : Cargo){
cargoes += c
}

def removeCargo(c : Cargo){
cargoes -= c
}
}

class AircraftCarrier with Ship with Airport

Java8里有了default method后,我们也可以做得漂亮:

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
public interface Airport {
// To be implemented in subclass
List<Aircraft> getAircrafts();

default void land(Aircraft aircraft) {
getAircrafts().add(aircraft);
}

default void depart(Aircraft aircraft) {
getAircrafts.remove(aircraft);
}
}

class AircraftCarrier implements Ship, Airport {
List<Aircraft> aircrafts = new ArrayList<>();
List<Cargo> cargoes = new ArrayList<>();

@Override
public List<Aircraft> getAircrafts(){
return aircrafts;
}

@Override
public List<Cargo> getCargoes(){
return cargoes;
}
}

还能继续扩展:

1
2
class Houseboat implements House, Ship { ... }
class MilitaryHouseboat implements House, Ship, Airport { ... }

kotlin也用default method来提供mixin功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface Ship {
val cargoes : List<Cargo>

fun addCargo(c : Cargo){
cargoes.add(c)
}

fun removeCargo(c : Cargo) {
cargoes.remove(c)
}
}

class AircraftCarrier : Ship, Airport {
override val cargoes = ArrayList()
override val aircrafts = ArrayList()
}

希望你也认同:mixin是比传统的inheritance更好的选择。

建议:

inheritance扩展性不好!用inheritance只能继承性质,而不能继承功能!mixin更适合于继承功能。

Go的mixin

go中我们一般通过组合来扩展功能。想象我们有一个接口Yeller

1
2
3
4
5
6
7
type Yeller interface {
Yell(message string)
}

func Yell(m Yeller, message string) {
m.Yell(message)
}

和一个实现了这个接口的类型Person

1
2
3
4
5
6
7
type Person struct {}

func (p *Person) Yell(message string) { /* yell */ }

// Let a person yell.
person := &Person{}
Yell(person, "No")

但假如我们不想让Person实现Yell,就需要通过组合的方式来做:

1
2
3
4
5
type Person struct {
Yeller Yeller
}

person := &Person{ /* Add some yeller to the mix here */ }

但此时Person本身还是没办法作为一个Yeller来使用,因为它没有实现Yell

1
2
// Won't work
Yell(person, "Loud")

相反,我们要显式通过Person.Yeller来调用Yell

1
2
// Will work
Yell(person.Yeller, "No")

否则就还是要为Person实现Yell接口。

但我们可以用mixin来扩展Person,方法就是只写成员类型,而不写成员名字:

1
2
3
4
5
6
type Person struct {
Yeller
}

p := &Person { /* Add some Yeller to the mix here */ }
Yell(p, "Hooray")

当我们把*Person作为Yeller使用时,go会先检查*Person支不支持Yell接口,如果不支持,再检查Person中是否有实现了Yell接口的没有名字的成员,有的话,就会认为*Person满足Yeller接口的要求,实际上调用了那个成员的方法。

这里Person.Yeller就是一个mixin。

而且Person还可以包含更多mixin,从而支持更多接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Yeller interface {
Yell(message string)
}

func Yell(y Yeller, message string) {
y.Yell(message)
}

type Whisperer interface {
Whisper(message string)
}

func Whisper(w Whisperer, message string) {
w.Whisper(message)
}

type Person struct {
Yeller
Whisperer
}

我们通过为Person混入两个mixin类型,令Person自动获得了两个接口的功能,且不需要写代码来实现功能的代理。这就是mixin的价值。

C++中通过mixin来重用功能

传统观念告诉我们通过继承来重用代码是邪恶的。大多数文章会鼓励用组合和代理代替继承来做代码重用。但组合与代理也会有它自己的问题,所以更好的方法是什么呢?

本文我会介绍C++中的mixin概念。它会告诉你在C++中通过mixin来重用代码并不邪恶,事实上它的诸多优点应该传播给每个C++程序员。

C++的mixin的教程多少会有些令人困惑,还会用一些生造的例子来让你纳闷为什么自己要学这么晦涩的技术。本文的目的是以易于理解的方式介绍mixin,它的实现,以及对比其它代码重用的方案。

例子

假设有个Task管理框架,可以做异步处理,Task接口为:

1
2
3
4
5
struct ITask {
virtual ~ITask() {}
virtual std::string GetName() = 0;
virtual void Execute() = 0;
};

我们的需求是有些通用功能应该能重用。这里用timing和logging来示例。

方法1:通过继承重用(邪恶)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// abstract base class which implements Execute() and provides logging feature. Derived classes must implement OnExecute().
class BaseLoggingTask : public ITask {
public:
void Execute() override {
std::cout << "LOG: The task is starting - " << GetName() << std::endl;
OnExecute();
std::cout << "LOG: The task has completed - " << GetName() << std::endl;
}

virtual void OnExecute() = 0;
};

// Concrete Task implementation that reuses the logging code of the BaseLoggingTask
class MyTask : public BaseLoggingTask {
public:
void OnExecute() override {
std::cout << "...This is where the task is executed..." << std::endl;
}

std::string GetName() override {
return "My task name";
}
};

上面的代码看起来挺合理的,该重用的代码都推到基类里了,代码很简洁很好懂。接下来处理timing:

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
// abstract base class which implements Execute() and provides task timing feature.  Derived classes must implement OnExecute().
class BaseTimingTask : public ITask {
Timer mTimer;
public:
void Execute() override {
mTimer.Reset();
OnExecute();
double t = mTimer.GetElapsedTimeSecs();
std::cout << "Task Duration: " << t << " seconds" << std::endl;
}

virtual void OnExecute() = 0;
};

// Concrete Task implementation that reuses the timing code of the BaseTimingTask
class MyTask : public BaseTimingTask {
public:
void OnExecute() override {
std::cout << "...This is where the task is executed..." << std::endl;
}

std::string GetName() override {
return "My task name";
}
};

但如果我们要同时重用logging和timing的代码该怎么办?两个Execute冲突了!这显示了这种“将重用部分放到基类”方法的主要局限性。某个时刻你会发现没办法把想重用的部分组合起来。

另一个问题是这里用了虚函数,而且是在虚函数内调用虚函数,这样编译器没办法inline,会增加开销。

方法2:通过组合与代理重用代码(还是邪恶,没那么严重)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class LoggingTask : public ITask {
std::unique_ptr<ITask> mTask;
public:
explicit LoggingTask(ITask* task) : mTask(task) {}

void Execute() override {
std::cout << "LOG: The task is starting - " << GetName() << std::endl;
if (mTask) mTask->Execute();
std::cout << "LOG: The task has completed - " << GetName() << std::endl;
}

std::string GetName() override {
if (mTask) {
return mTask->GetName();
} else {
return "Unbound LoggingTask";
}
}
};

概念上很简单,LoggingTask会转发请求给它持有的真正做事情的ITask。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class TimingTask : public ITask {
std::unique_ptr<ITask> mTask;
Timer mTimer;
public:
explicit TimingTask(ITask* task) : mTask(task) {}

void Execute() override {
mTimer.Reset();
if (mTask) mTask->Execute();
double t = mTimer.GetElapsedTimeSecs();
std::cout << "Task Duration: " << t << " seconds" << std::endl;
}

std::string GetName() override {
if (mTask) {
return mTask->GetName();
} else {
return "Unbound TimingTask";
}
}
};

上面两个类型也可以组合起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// MyTask is written with no coupling to the reusable logging and timing task
class MyTask : public ITask {
public:
void Execute() override {
std::cout << "...This is where the task is executed..." << std::endl;
}

std::string GetName() override {
return "My task name";
}
};

// Add timing and logging to MyTask through composition and delegation
std::unique_ptr<Itask> t(new LoggingTask(new TimingTask(new MyTask())));
t->Execute();

问题依旧,需要在堆上分配、需要判断null、有虚函数开销。

但它解决了两个功能无法组合在一个类里的问题。每块的解耦也不错。另一个优点是每个类都直接继承自ITask,不需要再搞出一层虚函数。

但我觉得它还有以下缺陷:

  • 有不必要的开销:堆分配、null检查、虚函数。
  • 像LoggingTask这样的类不想提供GetName这样的接口,但你继承自ITask就要实现这个接口。假如ITask还有其它接口,LoggingTask都必须实现。

因此我认为这种方法仍然是邪恶的。

方法3:重返继承(Clayton重用)

我们把“将重用部分放到基类”换成“将重用部分放到派生类”。我称它为Clayton重用。下面的例子看起来有点生硬,但它是通往mixin的铺路石。

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
class MyTask : public ITask {
public:
void Execute() override {
std::cout << "...This is where the task is executed..." << std::endl;
}

std::string GetName() override {
return "My task name";
}
};

class TimingTask : public MyTask {
protected:
void Execute() override {
std::cout << "(start timer)" << std::endl;
MyTask::Execute();
std::cout << "(end timer)" << std::endl;
}
};

class LoggingTask : public TimingTask {
protected:
void Execute() override {
std::cout << "LOG: The task is starting: " << GetName() << std::endl;
TimingTask::Execute();
std::cout << "LOG: The task has completed: " << GetName() << std::endl;
}
};

我们得到了什么?logging的代码与timing的代码与MyTask的代码耦合在了一起,完全没办法拿出来给其它类型重用。看起来全是缺点。

但除了代码耦合与缺乏重用,我们克服了以下问题:

  • 不需要堆分配。
  • 没有运行期检查null。
  • 不需要显式管理生命期。
  • 没有多余的虚函数定义及其调用。
  • 编译器有机会做更多优化。

因此,我们要找到方法解耦这些类,允许其它类重用它们。

方法4:通过mixin重用代码

mixin本身是一个很宽泛的概念,在C++中一般是通过参数化基类来实现:

1
2
template <typename T>
class MyMixin : public T {};

这就是解决上面方法3问题的关键。

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
32
template <typename T>
class LoggingTask : public T {
public:
void Execute() {
std::cout << "LOG: The task is starting - " << GetName() << std::endl;
T::Execute();
std::cout << "LOG: The task has completed - " << GetName() << std::endl;
}
};

template <typename T>
class TimingTask : public T {
Timer mTimer;
public:
void Execute() {
mTimer.Reset();
T::Execute();
double t = mTimer.GetElapsedTimeSecs();
std::cout << "Task Duration: " << t << " seconds" << std::endl;
}
};

class MyTask {
public:
void Execute() {
std::cout << "...This is where the task is executed..." << std::endl;
}

std::string GetName() {
return "My task name";
}
};

这里LoggintTask和TimingTask就是mixin,而MyTask则是想要重用这两个功能的具体类型。

通过mixin来构建一个对象太简单了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// A plain old MyTask
MyTask t1;
t1.execute();

// A MyTask with added timing:
TimingTask<MyTask> t2;
t2.Execute();

// A MyTask with added logging and timing:
LoggingTask<TimingTask<MyTask>> t3;
t3.Execute();

// A MyTask with added logging and timing written to look a bit like the composition example
typedef LoggingTask<TimingTask<MyTask>> Task;
Task t4;
t4.Execute();

但这样一个类没办法用于我们的Task管理框架,因为它没实现ITask接口,这就是最后一个mixin的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <typename T>
class TaskAdapter : public ITask, public T {
public:
void Execute() override {
T::Execute();
}

std::string GetName() override {
return T::GetName();
}
};

// typedef for our final class, inlcuding the TaskAdaTpter<> mixin
typedef TaskAdapter<LoggingTask<TimingTask<MyTask>>> ask;

// instance of our Task - note that we are not forced into any heap allocations!
Task t;

// implicit conversion to ITask* thanks to the TaskAdapter<>
ITask* it = &t;
it->Execute();

C++中解决mixin的构造问题

mixin是用于与其它类或mixin组合的碎片类。它与一个正常的,独立的类的区别在于,mixin只建模一个小块功能,且不倾向于独立使用。相反,它是用于组合到那些需要这个功能的类中。oop中的一种mixin模式包含了类与多重继承。这个模型下一个mixin就是一个类,我们通过多重继承来将多个mixin类组合起来。另一种模型是用参数化继承。这种场景下我们将一个mixin类表示为派生自它的模板参数的模板类:

1
2
template <typename Base> 
class Printing : public Base {...};

有些人称mixin类为“抽象子类”,即没有具体的基类的子类。C++里这种参数化继承的mixin已经用于实现高度可配置化的、协作式的、分层设计。

本文会展示一种解决C++中基于参数化继承的mixin会遇到的构造问题的解法。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class Customer {
public:
Customer(const char* fn, const char* ln)
: mFirstName(fn), mLastName(ln) {}
void Print() const {
cout << mFirstName
<< ' '
<< mLastName;
}
private:
const char* mFirstName,
const char* mLastName;
};

template <typename Base>
class PhoneContact: public Base {
public:
PhoneContact(const char* fn, const char* ln, const char* pn)
: Base(fn,ln), mPhone(pn) {}
void Print()() const {
Base::Print();
cout << ' ' << mPhone;
}
private:
const char* mPhone;
};

template <typename Base>
class EmailContact: public Base {
public:
EmailContact(const char* fn, const char* ln, const char* e)
: Base(fn,ln),mEmail(e) {}
void Print() const {
Base::Print();
cout << ' ' << mEmail;
}
private:
const char* mEmail;
};

int main() {
Customer c1("Teddy","Bear");
c1.Print(); cout << endl;
PhoneContact<Customer> c2("Rick","Racoon","050-998877");
c2.Print(); cout << endl;
EmailContact<Customer> c3("Dick","Deer","dick@deer.com");
c3.Print(); cout << endl;
// The following composition isn't legal because there
// is no constructor that takes all four arguments.
// EmailContact<PhoneContact<Customer> >
// c4("Eddy","Eagle","049-554433","eddy@eagle.org");
// c4.Print(); cout << endl;
return 0;
}

这里PhoneContactEmailContact是mixin类,可以简单地与Customer组合,但PhoneContace<EmailContact<Customer>>或反过来都不行,因为构造函数的参数数量不对。

最糟糕的解法是限制或改变mixin类组合的顺序,但也解不了这里的问题。我们讨论4种部分解法,它们某种角度会更好,但仍然会有诸如不必要的开销、调用代码臃肿、新增mixin不方便等问题。但这些解法也能帮助我们去理解更高级、更完善的解法。

部分解法

前述方法的问题在于不存在有合适的构造函数的mixin类。一个简单方法就是再增加一个有这样的构造函数的mixin类型,这就需要用到多重继承了。但这种方法需要大幅修改已有代码。首先就是要用多重继承。为了避免产生多个Customer子对象,我们需要把Customer变成虚基类。之后PhoneAndEmailContact就需要去初始化Customer了。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
class Customer {
public:
Customer(const char* fn, const char* ln)
: mFirstName(fn), mLastName(ln) {}
void Print() const {
cout << mFirstName
<< ' '
<< mLastName;
}
private:
const char* mFirstName,
const char* mLastName;
};

// The new mixin class will be defined using multiple inheritance.
// Therefore Base must be turned into a virtual base class.
template <typename Base>
class PhoneContact: virtual public Base {
public:
PhoneContact(const char* fn, const char* ln, const char* pn)
:Base(fn, ln), mPhone(pn) {}
void Print() const {
Base::Print();
BasicPrint();
}
protected:
// We need an "inner" print method that prints the PhoneContact-specific
// information only.
void BasicPrint() const {
cout << ' ' << mPhone;
}
private:
const char* mPhone;
};

// Base has to be declared as virtual base class here, too.
template <typename Base>
class EmailContact: virtual public Base {
public:
EmailContact(const char* fn, const char* ln, const char* e)
: Base(fn, ln), mEmail(e){}
void Print() const {
Base::Print();
BasicPrint();
}
protected:
// We need an "inner" print method that prints the EmailContact-specific
// information only.
void BasicPrint() const {
cout << ' ' << mEmail;
}
private:
const char* mEmail;
};

template <typename Base>
class PhoneAndEmailContact :
public PhoneContact<Base>,
public EmailContact<Base> {
public:
// Because Base is a virtual base class, PhoneAndEmailContact is now
// responsible for its initialization.
PhoneAndEmailContact(const char* fn, const char* ln, char* pn, const char* e)
: PhoneContact<Base>(fn, ln, pn)
, EmailContact<Base>(fn, ln, e)
, Base(fn, ln) {}
void Print() const {
Base::Print();
PhoneContact<Base>::BasicPrint();
EmailContact<Base>::BasicPrint();
}
};

一个问题是PhoneContactEmailContactPrint函数需要拆成PrintBasicPrint两个函数,后者不调用Base::Print,否则PhoneAndEmailContact::Print就会调用两次Base::Print,得不到想要的输出。如果我们还要增加新的mixin类型,比如PostalAddress,我们还要像这样再加3个mixin类型,随着功能的增加,我们需要组合出的mixin类型数量会以指数形式增加。

提供特定的参数类

一种方法是提供一个参数类,包含每个mixin需要的构造参数,这样不同的mixin都可以通过同样的参数对象来构造:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// Define a class that wraps the union of all constructor arguments 
// of Customer and all derived mixin classes.

// CustomerParameter combines all constructor arguments of CustomerParameter
// and its derived mixin classes. The default values for the last two
// arguments provide some convenience to the client programmer.
struct CustomerParameter {
const char* fn,
const char* ln,
const char* pn,
const char* e;
CustomerParameter(
const char* fn_,
const char*ln_,
const char* pn_ = "",
const char* e_ = "")
: fn(fn_), ln(ln_), pn(pn_), e(e_) {}
};

class Customer {
public:
explicit Customer(const CustomerParameter& cp)
: mFirstName(cp.fn), mLastName(cp.ln) {}
void Print() const {
cout << mFirstName
<< ' '
<< mLastName;
}
private:
const char* mFirstName,
const char* mLastName;
};

template <typename Base>
class PhoneContact: public Base {
public:
explicit PhoneContact(const CustomerParameter& cp)
: Base(cp), mPhone(cp.pn) {}
void Print() const {
Base::Print();
cout << ' ' << mPhone;
}
private:
const char* mPhone;
};

template <typename Base>
class EmailContact: public Base {
public:
explicit EmailContact(const CustomerParameter& cp)
: Base(cp), mEmail(cp.e) {}
void Print() const {
Base::Print();
cout << ' ' << mEmail;
}
private:
const char* mEmail;
};

这种方法也有问题。每当有新mixin类型时,我们都可能需要修改参数类。幸好已有的mixin类型不会受影响,但调用处在构造参数对象时可能要增加一个根本用不上的参数。

这种方法允许交换组合顺序,PhoneContact<EmailContact<Customer>>PhoneContact<EmailContact<Customer>>都可以,但Print的行为不一样。

提供Init方法

另一种方法是两段构造,为每个类型增加一个Init方法,这样你就背离了通用的C++准则:对象只通过构造函数初始化。这样每个类型只需要有默认构造函数,构造完成后再由用户显式调用Init函数:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// Define special intialization methods in each class and no longer rely 
// on the proper initialization though constructors.

class Customer {
public:
// Initialization method for Customer.
// A default constructor will be generated automatically.
void init(const char* fn, const char* ln) {
mFirstName = fn;
mLastName = ln;
}
void Print() const {
cout << mFirstName
<< ' '
<< mLastName;
}
private:
const char* mFirstName,
const char* mLastName;
};

template <typename Base>
class PhoneContact: public Base {
public:
// Initialization method for PhoneContact only.
// A default constructor will be generated automatically.
void init(const char* pn) {
mPhone = pn;
}

void Print() const {
Base::Print();
cout << ' ' << mPhone;
}
private:
const char* mPhone;
};

template <typename Base>
class EmailContact: public Base {
public:
// Initialization method for EmailContact only.
// A default constructor will be generated automatically.
void init(const char* e) {
mEmail = e;
}

void Print() const {
Base::Print();
cout << ' ' << mEmail;
}
private:
const char* mEmail;
};

这种方法很容易出错,因为它把正确构造的责任推给了用户。而且调用Init时还要带上类型名,太丑了。

额外定义构造函数

这种方法是为mixin增加额外的构造函数,来满足所有可能的基类的构造需求。因为mixin类型都是模板类,而模板类中没人使用的方法是不会被产生出来的,因此这些新增加的构造函数在没人用时不会增加目标代码的体积。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
template <typename Base>
class PhoneContact: public Base {
public:
// The following constructors will be instantiated only if required.
PhoneContact(
const char* fn,
const char* ln,
const char* pn)
: Base(fn, ln), mPhone(pn) {}
PhoneContact(
const char* fn,
const char* ln,
const char* pn,
const char* e)
:Base(fn, ln, e), mPhone(pn) {}
void Print() const {
Base::Print();
cout << ' ' << mPhone;
}
private:
const char* mPhone;
};

template <typename Base>
class EmailContact: public Base {
public:
// The following constructors will be instantiated only if required.
EmailContact(
const char* fn,
const char* ln,
const char* e)
: Base(fn, ln), mEmail(e) {}
EmailContact(
const char* fn,
const char* ln,
const char* pn,
const char* e)
: Base(fn, ln, pn), mEmail(e) {}
void Print() const {
Base::Print();
cout << ' ' << mEmail;
}
private:
const char* mEmail;
};

不得不说这种方法就是维护性的噩梦。且这种方法对构造顺序有要求,即我们要多个构造函数,才能同时允许PhoneContact<EmailContact<Customer>>PhoneContact<EmailContact<Customer>>

设计新方法

首先,提供一个参数类是个好方法,为不同类型的构造提供了统一的接口。但应该做到不同的mixin类型有不同的参数类。

其次,调用处应该像往常一样声明对象。

异构值列表

C++对象构造时要从最上层的基类开始,按继承顺序依次构造,直到最下层的派生类型。因此,基类的构造参数是派生类构造参数的一个子集。想象我们提供一个构造参数的列表,每个构造函数都从列表头部拿走自己想要的参数,再将剩下的列表传给它的基类构造器,直到列表为空。这里需要列表能容纳不同类型的对象,每个元素都可以有不同的类型。

一种实现这样的异构列表的方法是递归地实例化一个模板列表类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct NIL {};
template <typename Head_, typename Tail_ = NIL>
struct List {
typedef Head_ Head;
typedef Tail_ Tail;

List(const Head& h, const Tail& t = NIL())
: mHead(h), mTail(t) {}

Head mHead;
Tail mTail;
};

List<signed char, List<short, List<int, List<long>>>>l(
'a',
List<short, List<int, List<long>>>(
1,
List<int, List<long>>(
2,
List<Long>(3))));

配置仓库与参数适配器

接下来是组合两个不同的方法。第一个是用traits类作为配置仓库。配置仓库允许你将mixin类型的实现与它的构造参数分离。每个mixin类型的构造参数就是它对应的配置仓库。基础类型,也就是Customer,变成模板类,模板参数就是它的配置类型,再用这个配置类型来声明它的参数类型。而派生的mixin类型则使用基类的配置类型与参数类型来声明自己的参数类型。这样,配置仓库就可以沿着继承链一直走到底,每个mixin类型都可以从中获取自己想要的参数:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
template <typename Config_>
class Customer {
public:
typedef Config_ Config;
typedef List<
typename Config::LastnameType,
List<typename Config::FirstnameType>> ParamType;
explicit Customer(const ParamType& p)
: mFirstName(p.mTail.mHead), mLastName(p.mHead) {}
void Print() const {
cout << mFirstName
<< ' '
<< mLastName;
}
private:
typename Config::FirstnameType mFirstName;
typename Config::LastnameType mLastName;
};

template <typename Base>
class PhoneContact: public Base {
public:
typedef typename Base::Config Config;
typedef List<
typename Config::PhoneNoType,
typename Base::ParamType> ParamType;
explicit PhoneContact(const ParamType& p)
:Base(p.mTail), mPhone(p.mHead) {}
void Print() const {
Base::Print();
cout << ' ' << mPhone;
}
private:
typename Config::PhoneNoType mPhone;
};

template <typename Base>
class EmailContact: public Base {
public:
typedef typename Base::Config Config;
typedef List<
typename Config::EmailAddressType,
typename Base::ParamType> ParamType;
explicit EmailContact(const ParamType& p)
:Base(p.mTail), mEmail(p.mHead) {}
void Print() const {
Base::Print();
cout << ' ' << mEmail;
}
private:
typename Config::EmailAddressType mEmail;
};

template <typename Base>
struct ParameterAdapter: Base {
typedef typename Base::Config Config;

typedef typename Config::RET::ParamType P;
typedef typename P::N P1;
typedef typename P1::N P2;

template <typename A1>
explicit ParameterAdapter(const A1& a1)
: Base(a1) {}

template <typename A1, typename A2>
ParameterAdapter(const A1& a1, const A2& a2)
: Base(P(a2,a1)) {}

template <typename A1, typename A2, typename A3>
ParameterAdapter(const A1& a1, const A2& a2, const A3& a3)
: Base(P(a3,P1(a2,a1))) {}

template <typename A1, typename A2, typename A3, typename A4>
ParameterAdapter(
const A1& a1,
const A2& a2,
const A3& a3,
const A4& a4)
: Base(P(a4,P1(a3,P2(a2,a1)))) {}
};

struct C1 {
typedef C1 ThisConfig;

typedef const char* FirstnameType;
typedef const char* LastnameType;

typedef Customer<ThisConfig> CustomerType;

typedef ParameterAdapter<CustomerType> RET;
};

struct C2 {
typedef C2 ThisConfig;

typedef const char* FirstnameType;
typedef const char* LastnameType;
typedef const char* PhoneNoType;

typedef Customer<ThisConfig> CustomerType;
typedef PhoneContact<CustomerType> PhoneContactType;

typedef ParameterAdapter<PhoneContactType> RET;
};

struct C3 {
typedef C3 ThisConfig;

typedef const char* FirstnameType;
typedef const char* LastnameType;
typedef const char* EmailAddressType;

typedef Customer<ThisConfig> CustomerType;
typedef EmailContact<CustomerType> EmailContactType;

typedef ParameterAdapter<EmailContactType> RET;
};

struct C4 {
typedef C4 ThisConfig;

typedef const char* FirstnameType;
typedef const char* LastnameType;
typedef const char* PhoneNoType;
typedef const char* EmailAddressType;

typedef Customer<ThisConfig> CustomerType;
typedef PhoneContact<CustomerType> PhoneContactType;
typedef EmailContact<PhoneContactType> EmailContactType;

typedef ParameterAdapter<EmailContactType> RET;
};

这里C1C2C3C4就是不同的配置仓库,而它们的RET则是组合了不同mixin的目标类型。用法为:

1
2
3
4
5
6
7
8
C1::RET c1("Teddy","Bear");
c1.Print(); cout << endl;
C2::RET c2("Rick","Racoon","050-998877");
c2.Print(); cout << endl;
C3::RET c3("Dick","Deer","dick@deer.com");
c3.Print(); cout << endl;
C4::RET c4("Eddy","Eagle","049-554433","eddy@eagle.org");
c4.Print(); cout << endl;

ParameterAdapter的作用是提供一种通用的构造接口,使用C++11的变长模板会让它的实现更简洁,功能更强大。

通过结合使用这两种方法,我们得到了一种简洁的,统一的mixin构造方式。这种构造方式符合日常用法,只需要调用者关心自己用到的mixin类型,且是类型安全的。

CRTP

C++中有一种很特别的模式,称为Curiously Recurring Template Pattern,缩写是CRTP。从它的名字看,前三个词都是关键字。Curiously,意思是奇特的。Recurring,说明它是递归的。Template,说明它与模板有关。

最常见的CRTP形式就很符合这三个关键字:

1
2
3
4
5
6
7
8
template <typename T>
class Base {
...
};

class Derived : public Base<Derived> {
...
};

猛一看这段代码,确实挺奇特的:派生类继承自一个用派生类特化的基类,相当于自己特化了自己。

这里面应用到了C++模板的一个特性:与模板参数有关的代码的编译会推迟到模板实例化时进行。

静态多态

CRTP的第一个用途就是实现静态多态。

传统的C++中我们想要实现多态首先要有继承和虚函数:

1
2
3
4
5
6
7
8
9
10
11
class Base {
public:
...
virtual int Foo() = 0;
};

class Derived : public Base {
public:
...
int Foo() override;
};

并通过基类的指针或引用来触发多态:

1
2
3
void Func(Base& b) {
cout << b.Foo() << endl;
}

但这套方案有两个问题:

  1. 虚函数会影响类型的内存布局,空间上增加一个虚表指针。
  2. 虚函数调用需要增加一次跳转,增加了运行时开销。

而用CRTP,我们可以实现编译时的静态多态。在这个方案中,基类负责定义接口,而派生类则负责实现接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T>
class Base {
public:
...
int Foo() {
return static_cast<T*>(this)->Foo();
}
};

class Derived : public Base<Derived> {
public:
...
int Foo();
};

这个方案中,基类的Foo()会去调用派生类的Foo(),相当于前者是接口,而后者是实现。

注意在Base::Foo中,我们为了调用Derived::Foo,需要通过static_cast来显式转换this的类型。为什么这里用static_cast而不是dynamic_cast呢?因为Base自己是不知道T是它的派生类的,因此这里也不应该用dynamic_cast,而因为这里我们没有虚函数,用static_cast也是安全的。

CRTP方案的优点:

  1. 没有虚函数,不会改变派生类的内存布局,空间上开销更小。
  2. b.Foo()不是虚函数调用,不会增加一次跳转,运行时开销更小。
  3. Base::Foo()甚至可以内联掉,进一步降低了运行时开销。
  4. 模板对接口的要求是“Duck Typing”,比虚函数的要求更低。这个例子中,只要派生类满足有一个public的,名字为Foo,接受0个参数,返回类型可隐式转换为int的函数,就满足了Base的接口要求。

当然静态多态就导致了Base的不同的派生类实际继承自不同的基类,因此没有办法把它们的指针或引用放到某个容器中。另外,这样每个派生类都会实例化一个基类类型,会导致目标代码多于普通的继承。

mixin

CRTP的第二个用途就是为其它类型增加功能,此时CRTP的基类就是一种mixin类型。

当CRTP用于mixin时,它的写法与静态多态很类似,只不过此时我们要的不是多态,而是新的功能,因此基类与派生类的方法名要不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T>
class Repeatable {
public:
void Repeat(int n) const {
for (int i = 0; i < n; ++i) {
static_cast<const T*>(this)->Foo();
}
}
};

class ZeroPrinter : public Repeatable<ZeroPrinter> {
public:
void Foo() const {
cout << "0";
}
};

我们用CRTP为ZeroPrinter增加了一个Repeat功能,此时Repeatable就是一种mixin。而在这个方案中,我们不需要让ZeroPrinter去实现某个接口,去把自己已有的函数改成虚函数。

而且我们还可以为已经存在的类型增加功能。假如ZeroPrinter是第三方库提供的类型,我们没办法让它继承自Repeatable,那么我们可以增加一种新类型,同时继承ZeroPrinterRepeatable

1
2
class RepeatableZeroPrinter: public ZeroPrinter, public Repeatable<RepeatableZeroPrinter> {
};

注意,当我们用CRTP来实现mixin时,要注意派生类与基类的函数名不能相同,因为派生类会屏蔽掉基类的名字,而导致我们想增加的功能无法被使用。

另一个mixin的例子是Counter,我们可以利用Base<T>Base<R>不是一个类型的特性,为不同的类型增加实例个数的Counter统计的功能。

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
template <typename T>
struct Counter {
static int mObjectsCreated;
static int mObjectsAlive;

Counter() {
++mObjectsCreated;
++mObjectsAlive;
}

Counter(const Counter&) {
++mObjectsCreated;
++mObjectsAlive;
}
protected:
// objects should never be removed through pointers of this type
~Counter() {
--mObjectsAlive;
}
};
template <typename T> int Counter<T>::mObjectsCreated(0);
template <typename T> int Counter<T>::mObjectsAlive(0);

class X : Counter<X> {
// ...
};

class Y : Counter<Y> {
// ...
};

这个例子中,XY各自通过Counter<X>Counter<Y>来实现统计功能。

链式多态

假设有基类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Printer {
public:
explicit Printer(ostream& pstream) : mStream(pstream) {}

template <typename T>
Printer& Print(T&& t) {
mStream << t;
return *this;
}

template <typename T>
Printer& Println(T&& t) {
mStream << t << endl;
return *this;
}
private:
ostream& mStream;
};

我们可以链式调用:

1
Printer{myStream}.Println("hello").Println(500);

但派生类就不行:

1
2
3
4
5
6
class CoutPrinter : public Printer {
public:
CoutPrinter() : Printer(cout) {}

CoutPrinter& SetConsoleColor(Color c) { ... return *this; }
};
1
2
                             v-- we have a 'Printer' here, not a 'CoutPrinter'
CoutPrinter().Print("Hello ").SetConsoleColor(Color.red).Println("Printer!"); // compile error

因为print只会返回Printer&

用CRTP就可以解决这个问题:

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
32
33
// Base class
template <typename ConcretePrinter>
class Printer {
public:
explicit Printer(ostream& pstream) : mStream(pstream) {}

template <typename T>
ConcretePrinter& Print(T&& t)
{
mStream << t;
return static_cast<ConcretePrinter&>(*this);
}

template <typename T>
ConcretePrinter& Println(T&& t)
{
mStream << t << endl;
return static_cast<ConcretePrinter&>(*this);
}
private:
ostream& mStream;
};

// Derived class
class CoutPrinter : public Printer<CoutPrinter> {
public:
CoutPrinter() : Printer(cout) {}

CoutPrinter& SetConsoleColor(Color c) { ... return *this; }
};

// usage
CoutPrinter().Print("Hello ").SetConsoleColor(Color.red).Println("Printer!");

利用CRTP提供默认Clone

当要通过基类指针获得对象的拷贝时,通常做法是加个虚的Clone函数,而用CRTP可以避免在每个派生类中重复这个函数,只要派生类允许复制构造即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Base class has a pure virtual function for cloning
class Shape {
public:
virtual ~Shape() {}
virtual Shape* Clone() const = 0;
};

// This CRTP class implements clone() for Derived
template <typename Derived>
class Shape_CRTP : public Shape {
public:
Shape* Clone() const override {
return new Derived(static_cast<Derived const&>(*this));
}
};

class Square : public Shape_CRTP<Square> {
...
};

class Circle : public Shape_CRTP<Circle> {
...
};

摆脱static_cast

上面每个CRTP例子中都有static_cast,我们可以通过一个辅助类来避免每次都直接调用static_cast

1
2
3
4
5
template <typename T>
struct CRTP {
T& Underlying() { return static_cast<T&>(*this); }
T const& Underlying() const { return static_cast<T const&>(*this); }
};

这样前面的例子就可以写成:

1
2
3
4
5
6
7
8
emplate <typename T>
class Base : private CRTP<T>{
public:
...
int Foo() {
return this->Underlying().Foo();
}
};

注意1:这里要private继承,是因为我们不想把Underlying函数暴露出去。

注意2:这里为什么要用this->Underlying()而不是直接使用Underlying()?参见模板类中如何调用其模板基类中的函数

避免继承错误的基类

当我们写多个CRTP类型时,可能会因为copy/paste而不小心继承错基类:

1
2
3
4
5
6
7
class Derived1 : public Base<Derived1> {
...
};

class Derived2 : public Base<Derived1> { // bug in this line of code
...
};

解法很简单,将Base的构造函数声明为private,并将T设置为友元,这样Derived2根本就没办法调用Base<Derived1>的构造函数,从而制造编译错误:

1
2
3
4
5
6
7
8
template <typename T>
class Base {
public:
// ...
private:
Base(){};
friend T;
};

避免菱形继承

想象我们有两个mixin类型,都使用了CRTP来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T>
struct Scalable : private CRTP<T> {
void Scale(double multiplicator) {
this->Underlying().SetValue(this->Underlying().GetValue() * multiplicator);
}
};

template <typename T>
struct Squarable : private CRTP<T> {
void Square() {
auto v = this->Underlying().GetValue();
this->Underlying().SetValue(v * v);
}
};

现在我们把这两个功能加到一个类型上:

1
2
3
4
5
6
7
8
class Sensitivity : public Scalable<Sensitivity>, public Squarable<Sensitivity> {
public:
double GetValue() const { return mValue; }
void SetValue(double value) { mValue = value; }

private:
double mValue;
};

BOOM!编译错误:

1
error: 'CRTP<Sensitivity>' is an ambiguous base of 'Sensitivity'

问题出在我们不小心搞出来菱形继承了!

  • Sensitivity -> Scalable<Sensitivity> -> CRTP<Sensitivity>
  • Sensitivity -> Squarable<Sensitivity> -> CRTP<Sensitivity>

一种解法是将mixin的类型也加到CRTP的模板参数中:

1
2
3
4
5
6
7
8
template <typename T, template <typename> class CrtpType>
struct CRTP {
T& Underlying() { return static_cast<T&>(*this); }
T const& Underlying() const { return static_cast<T const&>(*this); }
private:
CRTP() {}
friend CrtpType<T>;
};

注意这里的CrtpType不是普通的模板参数类型,它前面的template说明它本身也是一个模板类型。我们没有直接用到CrtpType,只是用它保证同样的T加上不同的mixin会产生不同的CRTP类型。

新的Sensitivity的继承关系:

  • Sensitivity -> Scalable<Sensitivity> -> CRTP<Sensitivity, Scalable>
  • Sensitivity -> Squarable<Sensitivity> -> CRTP<Sensitivity, Squarable>

这样我们只要保证一个类型不要多次集成了同一个mixin,就没问题了。

相关链接

C++里类有4种特殊的成员函数:

  • 构造函数。
  • 析构函数。
  • 复制函数,包括复制构造函数和复制赋值函数。
  • 移动函数,包括移动构造函数和移动赋值函数。

这些函数的特点是:有些时候,编译器会帮你生成这些函数;有些时候,编译器又会拒绝生成这些函数;还有些时候,编译器还会往你自己写的特殊函数中添加操作。鉴于这些特殊函数的重要性,我们有必要好好了解一下它们背后的故事。

本文介绍的是后两类,复制函数和移动函数。

注1:本文环境为Ubuntu 16.04,gcc5.4.0,使用c++14标准。
注2:本文大量内容来自《深入探索C++对象模型》

复制函数

复制函数的签名

通常我们说到复制函数,是指:

  • 复制构造函数:S(const S& ano)
  • 复制赋值函数:S& operator=(const S&)

其中后者返回S&是为了能模仿C的连续赋值:a = b = c

复制构造函数还可以是S(S& ano),但这样会给人一种可能修改源对象的错觉。

复制赋值函数可以有很多签名,比如返回值改为const S&或者干脆是void,但这样的赋值函数可能不会有太多用处。如果把它的参数类型改为Sconst S,这也是复制赋值函数。如果参数类型为其它类型,则不再满足复制赋值函数的要求。

什么时候发生复制

如下三个场景都会调用到复制构造函数:

1
2
3
4
5
6
7
// case 0
S s1(s0);
// case 1
S s2 = s0;
// case 2
void Func(S s3);
Func(s0);

实际上,这也是构造函数会被调用的三个场景,只是它的参数恰好与类相同。

而当我们对一个已定义的对象进行赋值时,如果=右边的表达式类型与类相同(去掉cv与左值引用后),那么就会调用复制赋值函数。

1
2
s1 = s0;
s2 = GetTempS();

编译器生复制函数的条件

当一个类满足以下条件时,编译器就会为其生成复制构造函数或复制赋值函数:

  • 没有自定义的复制函数或声明复制函数为= default,且
  • 没有用= delete删除复制函数,且
  • 所有非静态成员变量都可复制,且
  • 代码中调用了复制函数。

其中,如果类定义了一个复制函数而没有定义另一个,且满足上述条件,那么编译器也会为其生成另一个复制函数。

根据上面的要求,如果一个类有const成员或引用类型的成员,那么这个类显然没办法复制了。

然而我们又要重复说bitwise与memberwise了。

如果编译器在为一个类生成复制函数时,发现这个类满足bitwise标准,只需要trivial的复制函数,那么就不会真的生成这个函数。前文中我们说,对于满足bitwise构造的类型来说,不建议放任编译器生成trivial的默认构造函数,原因是trivial的默认构造函数会导致成员变量没有确定的初值。但与默认构造函数不同,bitwise的复制函数的确定性取决于它的参数值,因此一个类型如果复制函数是trivial的,但通过自定义默认构造函数的方式保证了不会有trivial的默认构造函数,那么trivial的复制函数就是安全的。

而如果类不满足bitwise条件,编译器就会为其生成memberwise的复制函数,即按声明顺序,依次调用每个非静态成员变量的相应复制函数。

复制与析构的“三法则”

以下内容来自Effective Modern C++ Item17

C++98中有所谓的“三法则”:如果你声明了复制构造函数、复制赋值函数或析构函数中的一个,你也应该定义另外两个。该原则的原因是如果你声明了其中任意一个函数,就表明你要自己管理资源,而这三个函数都会参与到资源管理中,因此如果声明就要全声明掉。STL中的每个容器类都声明了这三个函数。

三法则的一个推论就是,自定义了析构函数往往意味着逐一的复制语义并不适用于这个类,因此自定义析构函数也应该阻止编译器生成复制函数。但在C++98标准产生过程中,三法则还没有被广泛认可,因此C++98中自定义析构函数并不会影响编译器生成复制函数。

设置虚表指针

当发生派生类到基类对象的复制时,很重要的事情就是保证基类对象的虚表指针指向正确的虚表。对于Base b = Derived()这种情况,我们知道其中发生Slicing,即只有Derived对象中的基类子对象复制给了b。但其中编译器还会正确地设置虚表指针,保证这样构造出来的b的虚表指针指向的是Base的虚表,而不是Derived的虚表。

如果我们自定义了复制函数,编译器会插入相应的代码,保证复制过程中虚表指针被正确设置了。

正确复制基类子对象

我们在实现派生类的复制函数时,通常会比较注意是不是有成员忘了处理。但除了派生类本身的成员变量外,有时候我们可能会忘了复制基类子对象:

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
struct Base {
Base(): x(0) {}
Base(const Base& b): x(b.x) {}
Base& operator=(const Base& b) {
x = b.x;
}
int x;
};

struct Derived: public Base {
Derived(): y(1) {}
Derived(const Derived& d): y(d.y) {}
Derived& operator=(const Derived& d) {
y = d.y;
}
int y;
};

int main() {
Derived d0;
d0.x = 2;
d0.y = 2;
Derived d1 = d0;
printf("%d %d\n", d1.x, d1.y);
d1.x = 3;
d1 = d0;
printf("%d %d\n", d1.x, d1.y);
}
1
2
0 2
3 2

这个例子中Derived的两个复制函数都错了。

复制构造函数中,没有显式调用基类的复制构造函数,导致基类子对象被默认构造,丢失了d0.x。而复制赋值函数中,忘记调用了基类的复制赋值函数,同样导致d1.x没有被修改。

此时似乎编译器应该介入,但回想一下C++的设计理念,充分信任程序员,如果作者真的就预期这种行为呢?所以编译器不会帮我们把基类复制好。

因此一定要记住,在派生类的复制构造函数中,成员初始化列表的第一项就要是基类:

1
Derived::Derived(const Derived& d): Base(d), y(d.y) {}

而在复制赋值函数中,没有初始化列表,我们就在第一行显式调用基类的复制赋值函数:

1
2
3
4
Derived& Derived::operator=(const Derived& d) {
Base::operator=(d);
y = d.y;
}

说实话这么写代码有点丑,但我们还是要认清现实,毕竟保证正确性更重要。

判断是否为自身

本节与下节来自《Effective C++》的Item11(“Handle assignment to self in operator=”)与Item25(“Consider support for a non-throwing swap”)。

当复制赋值函数被调用时,如果不判断源与目标是否为相同对象,可能会导致资源泄漏甚至进程崩溃。比如有这么个类:

1
2
3
4
5
6
7
8
9
10
11
class Widget {
...
private:
Bitmap* pb;
};

Widget& Widget::operator=(const Widget& rhs) {
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}

如果这里rhs*this是相同对象:

  • pb被删除,析构了,之后pb变成了空悬指针。
  • 解引用pb导致未定义行为。

因此传统做法是在赋值函数中先判断一下是否为自身:

1
2
3
4
5
6
7
Widget& Widget::operator=(const Widget& rhs) {
if (this != &rhs) {
delete pb;
pb = new Bitmap(*rhs.pb);
}
return *this;
}

然而这个函数还是有问题:它不是异常安全的,如果new Bitmap抛了异常,被赋值的对象的pb已经析构,没办法恢复了。怎么解决呢?看下节。

使用swap实现异常安全的复制

对于上节提到的Widget赋值的异常安全问题,传统做法是先复制,再赋值:

1
2
3
4
5
6
7
8
Widget& Widget::operator=(const Widget& rhs) {
if (this != &rhs) {
Bitmap* pOrig = pb;
pb = new Bitmap(*rhs.pb);
delet pOrig;
}
return *this;
}

然而更优雅的方式为Widget增加一个swap函数,并直接利用复制构造函数:

1
2
3
4
5
6
7
Widget& Widget::operator=(const Widget& rhs) {
if (this != &rhs) {
Widget tmp(rhs);
swap(tmp);
}
return *this;
}

这种方式将复制相关的逻辑都集中在了一个地方,复制构造函数中,更不容易弄错,代码也更整洁。

接下来是一个小要求:保证swap不抛异常。各个类型的swap很多时候都是用来实现强的异常安全的,因此会有swap不抛异常的假设。如果打破了这个假设,当我们用一些第三方库时,可能会遇到麻烦。一般来说,如果我们用指针或智能指针或STL容器来管理资源,还是很容易保证swap不抛异常的。

如果我们能假设Widget不会自身赋值,那么还有一种写法,可以更简洁地实现赋值:

1
2
3
4
Widget& Widget::operator=(Widget rhs) {
swap(rhs);
return *this;
}

这里我们利用了函数参数,省掉了一个临时变量,且因为rhs现在是一个local变量,不可能与*this相同,我们也不需要判断是否是自身,函数实现只剩下了两行代码。

禁止复制

有些类型并不适合被复制,比如一些用于RAII的类型。为了安全,我们需要禁止这些类型的复制。

C++98中常用的做法是声明private的复制函数但不实现:

1
2
3
4
5
6
class Lock {
...
private:
Lock(const Lock&);
Lock& operator=(const Lock&);
};

这样类的外部(非友元)因访问权限问题而无法调用到复制函数,类的内部和友元则因复制函数没有定义,在链接时会报错。

在C++11中我们可以声明这些函数为deleted,从而更优雅更明显地禁止复制:

1
2
3
4
5
6
class Lock {
public:
Lock(const Lock&) = delete;
Lock& operator=(const Lock&) = delete;
...
}

更多内容参见Effective Modern C++ Item11

赋值函数可以是虚函数吗

我们知道构造函数不可以是虚函数,那么赋值函数可以是虚函数吗?比如:

1
2
3
4
5
6
7
8
struct Base {
virtual ~Base() {}
virtual Base& operator=(const Base& b);
};

struct Derived: public Base {
virtual Derived& operator=(const Derived& d);
}

这样下面的调用就有多态的效果了:

1
2
3
4
5
int main() {
Base* p0 = new Derived();
Base* p1 = new Derived();
*p0 = *p1;
}

然而很遗憾,错了,没有多态。编译器在判断派生类有没有改写基类的虚函数时,会判断返回值类型是否兼容,但不会判断参数类型是否兼容,因此实际上Derived中有两个赋值函数:

1
2
3
4
struct Derived: public Base {
virtual Derived& operator=(const Base&);
virtual Derived& operator=(const Derived&);
}

其中第一个赋值函数继承了基类的实现。因此*p0 = *p1实际调用的还是基类的赋值函数,只复制了基类子对象部分。

怎么改正确?为了正确赋值派生类自己的成员,我们需要用到RTTI:

1
2
3
4
5
6
7
8
Derived& Derived::operator=(const Base& b) {
Base::operator=(b);
const Derived* p = dynamic_cast<const Derived*>(&b);
if (p) {
// 赋值派生类的成员
}
return *this;
}

之后我们该怎么处理Derived“正常”的那个赋值函数呢?就是参数为const Derived&那个。

我们可以同样实现它,但这样Derived中就需要实现两个赋值函数,而派生自Derived的类型则需要实现三个,等等,太恐怖了。另一种做法是干脆不要这个“正常”的赋值函数,这样所有赋值都会走基类定义的那个虚函数。这样我们只需要实现一个赋值函数,但缺点是所有赋值都要用到RTTI,开销比较大。

这两种方法都不太好,看起来它们指向一个结论:赋值函数不应该是虚函数。通常我们认为重载的操作符函数都不适合作为虚函数。

那么如果我们真的要实现一种多态的复制操作,该怎么做?一种常见做法是基类定义一个虚的Clone函数:

1
2
3
4
struct Base {
...
Base* Clone() const = 0;
};

这样各个子类只要实现自己的Clone函数,就可以完成多态的复制了。

参考

移动函数

移动函数可以参考以下文章:

本文就不再重复了。

返回值优化(RVO)

所谓RVO就是Return Value Optimization,是一种编译器优化,即当编译器返回一个local变量时,如果接收返回值的是一个相同类型的新对象(即构造,而不是赋值),编译器可能会省掉这次构造,就在这个返回值的内存位置构造这个local变量:

1
2
3
4
5
6
7
8
9
vector<string> GetNameList(int n) {
vector<string> l;
for (int i = 0; i < n; ++i) {
l.push_back(GetRandomName());
}
return l;
}

vector<string> nameList = GetNameList(100);

如果没有RVO,这个例子中会有两次vector<string>的构造,一次是默认构造,一次是复制构造。但在有RVO时,GetNameList中的l实际就是nameList,编译器直接用了返回值的地址来构造l,这样最后就不需要真正“返回”一个值了,省掉了100个string的复制。

这里能体现RVO的几个要求:

  • 返回local变量的值,而不是指针、引用、或是local变量的成员。
  • 接收变量要是新对象,不能是已有的对象,即这个表达式是用来构造它的,而不是给它赋值的。
  • 接收变量的类型要与local变量的类型完全相同。

C++98中RVO属于编译器自己的一种优化,我们不能预期编译器真的会执行优化。因此上面的写法通常不被推荐,我们更习惯这么写:

1
void GetNameList(int n, vector<string>* nameList);

这能保证省掉一次复制构造。但在C++11中,标准规定了满足上述要求后,编译器必须使用RVO,RVO成了可预期的行为,那么我们就可以放心使用前面的写法了,毕竟它更干净。

有些人会为了省掉一次复制构造,而选择返回local变量的右值引用:

1
2
3
4
5
vector<string> GetNameList(int n) {
vector<string> l;
// ...
return std::move(l);
}

引用下面链接中黄尼玛的回答:

此时返回的并不是一个局部对象,而是局部对象的右值引用。编译器此时无法进行rvo优化,能做的只有根据std::move(w)来移动构造一个临时对象,然后再将该临时对象赋值到最后的目标。所以,不要试图去返回一个局部对象的右值引用。

引用Effective Modern C++ Item25

如果函数的返回类型就是值类型,那么编译器可以直接将这个local对象构造在接收函数返回值的对象上,省掉中间的复制过程。换句话说,在RVO的帮助下,直接返回这个local对象要比返回它的右值还要节省。

既然直接返回local对象不会比手动调用std::move差,还有很大概率更好一些,我们还有什么理由去手动move呢?

参考

C++里类有4种特殊的成员函数:

  • 构造函数。
  • 析构函数。
  • 复制函数,包括复制构造函数和复制赋值函数。
  • 移动函数,包括移动构造函数和移动赋值函数。

这些函数的特点是:有些时候,编译器会帮你生成这些函数;有些时候,编译器又会拒绝生成这些函数;还有些时候,编译器还会往你自己写的特殊函数中添加操作。鉴于这些特殊函数的重要性,我们有必要好好了解一下它们背后的故事。

本文介绍的是前两类,构造函数和析构函数。

注1:本文环境为Ubuntu 16.04,gcc5.4.0,使用c++14标准。
注2:本文大量内容来自《深入探索C++对象模型》

构造函数

什么类没有构造函数

我们知道构造函数是一种非常重要的函数,也是C++诞生的一个主要原因。那么,第一个问题,每个类都有构造函数吗?

对于下面这个平凡类:

1
2
3
4
5
6
7
8
9
10
struct Trivial {
int64_t x;
int64_t y;
};

int main() {
Trivial t;
t.x = 1;
t.y = 2;
}

main函数对应的汇编指令为(未开任何优化):

1
2
3
4
5
6
7
8
9
10
00000000004006b6 <main>:
4006b6: 55 push %rbp
4006b7: 48 89 e5 mov %rsp,%rbp
4006ba: 48 c7 45 f0 01 00 00 movq $0x1,-0x10(%rbp)
4006c1: 00
4006c2: 48 c7 45 f8 02 00 00 movq $0x2,-0x8(%rbp)
4006c9: 00
4006ca: b8 00 00 00 00 mov $0x0,%eax
4006cf: 5d pop %rbp
4006d0: c3 retq

没有Trivial的构造函数的影子。整个binary中也找不到Trivial的构造函数。

事实上,平凡类就是没有构造函数的,或者说编译器会为它生成一个trivial的构造函数。而一个trivial的构造函数就类似于C中struct的初始化:什么都不做。因此编译器实际上不会为平凡类生成构造函数。而平凡类不允许有自定义的构造函数,结论就是平凡类就不可能有构造函数。

然而有一种情况下,编译器还真会给平凡类生成一个构造函数,那就是显式声明一个= default的构造函数:

1
2
3
4
5
6
struct Trivial {
int64_t x;
int64_t y;

Trivial() = default;
};
1
2
3
4
5
6
7
8
9
10
00000000004009e8 <_ZN7TrivialC1Ev>:
4009e8: 55 push %rbp
4009e9: 48 89 e5 mov %rsp,%rbp
4009ec: 48 89 7d f8 mov %rdi,-0x8(%rbp)
4009f0: 90 nop
4009f1: 5d pop %rbp
4009f2: c3 retq
4009f3: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
4009fa: 00 00 00
4009fd: 0f 1f 00 nopl (%rax)

虽然这个函数里明显什么事情都没做,但它确实存在了。

当然,加上“-O2”你会发现,它又没了。

实际上,所有没有自定义构造函数的,满足bitwise语义的类,都可能没有构造函数。这个范围会比平凡类大一些。

编译器生成默认构造函数的条件

第二个问题,编译器什么时候会为一个类生成一个默认构造函数?

编译器只会在必要的时候为一个类生成默认构造函数。所谓必要,指:

  • 这个类没有自定义的构造函数或声明默认构造函数为= default,且
  • 没有用= delete删除默认构造函数,且
  • 代码中调用到了这个类的默认构造函数。

第一个条件很好理解,C++的编译器是充分相信程序员的,如果一个程序员写了随便一个构造函数,编译器会尊重Ta,不再为其生成默认构造函数。

第二个条件是指,编译器不会在看到这个类的定义时就为其生成一个默认构造函数,而是会推迟这个生成时机,直到有代码真的调用了才生成。

然而,即使满足上面的条件,如果类中默认构造函数没有声明为= default,且编译器判断这个类可以bitwise构造,编译器仍然不会真的生成一个默认构造函数。

bitwise与memberwise

当我们说到构造函数时,一个不得不提的概念是bitwise与memberwise。实际上这两个概念更多的是用来描述拷贝,但在构造上也有着类似的效果。

一个类型,如果满足:

  • 是标量类型,或
  • 是自定义类型,且满足:
    • 没有虚函数。
    • 没有虚基类。
    • 没有不符合bitwise语义的非静态成员变量。
    • 没有不符合bitwise语义的基类。
    • 没有自定义的构造函数。

那它就满足bitwise构造的条件,即它在构造时没有任何特殊的操作(除了给它分配内存外),它的默认构造函数就是trivial的,实际上编译器不会真的生成这个函数。

而反过来,不满足这个条件的类,它就需要依次初始化每个成员,即是memberwise。

trivial的构造函数要比自定义的构造函数低很多(什么都不做),但它伤害到了正确性,即类的成员是没有一个可预期的初始值的的。从这个角度讲,即使是满足上面条件的类,我们也不应放任编译器选择trivial的默认构造函数,而应该自己定义一个正确初始化每个成员值的默认构造函数。当然,如果你要定义一个POD类型的话,除外。

构造函数的内容

一个构造函数有哪些内容?比如,对于一个有着两个基类A和B的有虚函数的类型C,它的构造函数需要完成以下工作:

  • 初始化基类A。
  • 初始化基类B。
  • 确保虚表指针指向正确的位置。
  • 初始化每个成员。
  • 依次调用构造函数体中的语句。

很显然,C要先完成A和B的构造,才能保证C自己的构造过程开始时,它已经“is a”A和B的对象。

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
struct A {
int32_t x;
A() {
x = 5;
F();
}
virtual ~A() {}
virtual void F() {}
};

struct B {
int32_t y;
B() {
y = 1;
}
virtual ~B() {}
};

struct C: public A, public B {
int32_t z;
C(): z(3) {}
void F() {
cout << z << endl;
}
};

这个例子中,在A::A中我们调用了一个虚函数F,假如整个过程发生在C的构造中,它调用的是哪个FA::F还是C::F

我们知道,在C去构造它的A的子对象时,它自己的成员都还没有初始化,那么此时去调用C::F显然是不合理的。因此我们有一个结论:构造函数中虚函数没有动态绑定,只有静态绑定。

《深入探索C++对象模型》中提到,当时的编译器在初始化基类子对象时,是通过将虚表指针指向基类的虚表,来实现一种伪的静态绑定。这么做的理由是不区别对待构造函数与其它函数。但这显然会伤害到效率。因此现代的编译器都会区别对待构造函数,真的静态绑定其中每个成员变量的调用。

通常我们会显式的把基类的构造写到派生类的初始化列表中。但即使不这么做,编译器也会将基类子对象的构造插到派生类的每个构造函数的开头,当然这要求基类有一个默认构造函数,或编译器能为其生成一个默认构造函数。

OK,在成功地构造完基类子对象后,C开始忙自己的构造了。

首先,如果C有虚表,那么要把虚表设置到正确地位置上。

之后开始按声明顺序依次构造C的每个非静态成员变量。

这里的“声明”要加粗,是因为如果忽略这一点,我们很可能会得到一个编译器的警告。

在这个阶段,每个非静态成员变量的初始值可能会来自三个地方:

  • 初始化列表中的表达式。
  • 成员初始化式(C++11新增)中的表达式。
  • 该成员的默认构造函数(如果非trivial)。

其优先级依次下降。其中最后一项不涉及顺序,而前两项都可能会涉及到不同成员变量间的构造顺序。

对于下面这个例子:

1
2
3
4
5
6
struct S {
int x;
int y = z + 2;
int z;
S(): z(0), x(y + 1) {}
};

当我们构造出一个S的对象时,会发现它的xy两个成员的值是未初始化的!这就是因为,x依赖了它后面声明的y,而y依赖了它后面声明的z,导致当它们进行初始化时,依赖的值都还没有初始化,自然会得到一个错误的值。

OK,初始化列表结束后,此时c已经是一个合法的,所有成员和函数都可用的C对象了。接下来要执行的就是构造函数体本身了。

这里有一个值得注意的点。执行到构造函数体前,类的所有成员变量都已经初始化过了,如果我们在构造函数体中再对其进行赋值,大概率浪费了前面的初始化:

1
2
3
4
5
6
struct S {
std::string name;
S(const std::string& s) {
name = s;
}
}

这个例子中,在整个构造过程中,name执行了一次默认构造函数,和一次赋值。而如果将这次赋值放到初始化列表中:

1
2
3
4
struct S {
std::string name;
S(const std::string& s): name(s) {}
}

name就只执行了一次复制构造函数。对于很多类型来说,后者的好处还是很明显的。

单参数构造函数要声明为explicit

某种说法认为C++不是强类型语言,因为它允许类型间的隐式转换。C++中的隐式转换有一部分是因为要兼容C而背的包袱,导致整型的重载无比混乱。而另一部分隐式转换就是C++自己设计的问题了。

在某些场景下,C++的隐式转换是很有用的,但在很可能多得多的场景下,如果滥用隐式转换,就会带来潜在的问题。

1
2
3
4
5
6
7
8
9
10
11
12
struct S {
std::vector<std::string> v;
S(int x): v(x, "aaa") {}
};

void Func(const S& s) {
std::cout << "v.size:" << s.v.size() << std::endl;
}

int main() {
Func(100); // oops!
}

这个例子中,Func实际只接受const S&类型的参数,但我们搞错了,传进去了100。我们预期的结果当然是编译器报错,找不到Func(int),但实际呢?程序编译通过,成功运行,结果是:

1
v.size:100

发生了什么?隐式转换。编译器看到Func(100)时,它首先会去找Func,只找到了Func(const S&),没找到Func(int)。于是编译器会找有没有哪种隐式转换,允许将一个int转换为S,还真有,S正好有个构造函数是S(int)!于是编译器这里就执行了S的构造函数,构造出一个有着100个元素的对象。

怎么避免上面的场景发生?我们就要想办法禁止intS的隐式转换,而explicit就是这个作用。当它被用来修饰一个单参数的构造函数时,就会阻止编译器产生一种隐式转换的关系:

1
2
3
4
struct S {
std::vector<std::string> v;
explicit S(int x): v(x, "aaa") {}
};

然而当我们真的想将int转换为S时,该怎么办?两种方法:

  • 显式构造:Func(S(100))
  • static_cast:Func(static_cast<S>(100))

全局变量的初始化不要依赖其它编译单元的全局变量

这句话有两个前提:

  • 全局变量的初始化发生在main函数之前,串行进行。
  • 不同的实现文件(.cpp或.cc)属于不同的编译单元,而不同编译单元的全局变量的初始化顺序在链接时由链接器决定。

这就导致了一个类似于上面构造函数初始化列表的顺序问题,且它没有一个确定的顺序。

因此,如果一个全局变量在初始化时依赖了另一个编译单元的全局变量,很可能你会发现前者初始化时后者还没有初始化。

这里的全局变量也包括类的静态成员变量。

那么,如果真有这种全局变量的初始值依赖于其它变量,该怎么做呢:

  • 相同编译单元的全局变量的初始化顺序是确定的,可依赖的。

  • 如果必须跨编译单元依赖,那么把被依赖的变量放到一个函数里作为static变量。标准规定了函数中的static变量其初始化是在第一次调用时(运行到此行时),这是确定的,可依赖的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // a.cpp
    TypeA gX(SomeFunc());
    // b.h
    const TypeB& SomeFunc();
    // b.cpp
    const TypeB& SomeFunc() {
    static TypeB b;
    return b;
    }

    别担心这里构造static变量没加锁,C++11后标准中规定了这种构造是线程安全的。

析构函数

编译器生成析构函数的条件

与默认构造函数类似,编译器为一个类生成析构函数的条件为:

  • 这个类没有自定义的析构函数或声明析构函数为= default,且
  • 没有用= delete删除析构函数,且
  • 代码中调用到了这个类的析构函数。

同样地,如果一个类符合bitwise析构的标准,编译器为它生成的析构函数就是trivial的,就是可以忽略的,此时这个类就没有析构函数了。

析构函数的内容

析构函数实际就是构造函数的逆过程。参考前面的类C,它的析构函数有以下内容:

  • 依次调用析构函数体中的语句。
  • 声明逆序调用每个成员变量的析构函数。
  • 声明逆序调用每个基类子对象的析构函数。

同样地,析构函数中也会遇到虚函数的绑定问题。与构造函数类型,所有出现在析构函数中的虚函数,都是静态绑定,因为在析构基类子对象时,派生类自己的成员已经都析构掉了,此时再调用派生类改写的方法大概率会出问题。

有虚函数的类也需要一个虚析构函数的定义

这里有两个值得注意的点。

第一个,一个类有虚函数,但析构函数不是虚函数,会有大问题的。我们为一个类增加虚函数时,一定是准备实现运行期多态的(否则声明虚函数干什么)。而我们知道运行期多态是要靠基类的指针和引用来触发的。对于下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct Base {
~Base() {
std::cout << "~Base" << std::endl;
}
virtual void F() {}
};

struct Derived: public Base {
~Derived() {
std::cout << "~Derived" << std::endl;
}
std::string name;
};

int main() {
Base* p = new Derived;
delete p; // oops!
}
1
~Base

没有调用真正类型的析构函数,是个大问题!尤其是,Derived::name也没有被析构,出现了内存泄漏!

因此,第一个结论:有虚函数的类,一定要有虚的析构函数。

第二个值得注意的点,纯虚基类,析构函数也要有定义,不能是纯虚函数:

1
2
3
4
struct Base {
virtual ~Base() = 0;
virtual void F() = 0;
}

它的问题在于,它的所有派生类在析构时都会调用到Base的析构函数,然而发现这个函数没有定义!编译会因此失败。

因此,第二个结论:不能有纯虚的析构函数,也不能禁止生成析构函数(通过声明为= delete或声明为private却不给定义),一定要给析构函数一个定义(或等待编译器为你生成一个)。

变量的析构时间

标准规定了一个local变量的析构时间是在它出scope时,这个规则很简单,但有些特殊场景还是要单独说一下:

  • 全局变量、静态变量的构造时间是在main函数以前,而析构时间则是在main函数以后。同样地,不同编译单元间全局/静态变量的析构函数也是不确定的。

  • 临时变量的析构时间为代码中其所在的最外层表达式执行完成后,即:

    1
    2
    const char* s = getStringObject().c_str();
    // temp obj destructs here and s becomes danling!

    这里getStringObject()会返回一个临时的std::string对象,这个对象的生命期会直到完成s的赋值后,下一行调用开始前。

  • 但被赋值给const引用的临时变量,其生命期会与这个const引用保持一致,直到这个引用出scope才析构:

    1
    2
    3
    4
    {
    const std::string& s = getStringObject();
    //...
    } // temp obj destructs here
  • 如果goto跳回到函数前面,则这段代码中定义的变量都会被析构:

    1
    2
    3
    4
    RETRY:
    std::string name;
    std::vector<int> v;
    goto RETRY; // name and v will be destructed.
  • 存在短路逻辑的表达式中,编译器需要插入一些代码才能确定临时对象的析构时间:

    1
    2
    3
    if ((s + t) || (u + v)) {
    // ...
    }

    这里面s + t会产生临时对象,但u + v只能说可能产生临时对象,因为如果表达式被短路,根本走不到后半截,就不会产生这个临时对象,那么这个临时对象也就不需要被析构。编译期怎么会知道这个表达式会不会被短路呢?因此编译器需要插入一些代码来产生不同分支。这也稍稍增加了些运行期的开销。

当一个函数有着很多出口时,想决定一个local变量的scope就变困难了,编译器需要在每个可能return的地方都加上一些用于析构已经存在的变量,这也会增大binary的体积。

不要手动调用local变量的析构函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Base {
int j;
virtual void f();
};

struct Derived: public Base {
void f() {}
};

void func() {
Base b;
b.f();
b.~Base();
new(&b) Derived;
b.f(); // which f?
} // which dtor?

上面是一个很tricky的例子,我们手动调用了b的析构函数,又在其原地构造了一个派生类对象。此时再调用f,调用的会是哪个版本?出scope时,调用的是谁的析构函数?

实际这都是未定义的问题,编译器很可能不会按我们的想法去实现。因此结论就是不要这么用。

异常场景

当有了异常之后,构造函数和析构函数就更复杂了:

  • 构造函数抛了异常后,已经构造完的基类子对象和成员变量要析构,但未构造的成员不要析构,因此编译器需要插入大量代码。
  • 构造函数如果在进入函数体之前抛异常,此时对象本身还不完整(有成员未构造完),那么就不能执行析构函数。
  • 异常会导致函数栈unwind,期间每个还存活的对象都要析构,同样需要插入大量代码。
  • 异常还可能会被catch住,此时unwind停止,不再析构存活对象,又要做一些判断。
  • 当异常未被catch住时,如果unwind导致的析构抛了异常,同时存在两个异常会导致程序crash。

因此异常是一种比较昂贵的特性,想实现好异常安全也不那么容易,比如STL容器为了保证修改时的异常安全,做了非常多的事情。

但辩证的看,异常本身还是一种很有用的特性,至少我是支持使用异常的,只要知道上面这些开销,尽量避免错误的使用就好了。

当C++刚刚问世时,它的两大卖点是:

  • 与C兼容。
  • 面向对象。

而说到面向对象时,就绕不开多态。说到多态,就绕不开继承。

所谓多态,即同样的代码,不同的行为。根据这种行为差异的发生时机,我们把多态分成了编译时多态和运行时多态。继承能实现的就是运行时多态。

本文想讨论的是C++为继承和运行时多态准备了什么样的对象模型

  • 注1:本文中的“多态”特指“运行时多态”。
  • 注2:本文不讨论虚继承及其背后的对象模型。
  • 注3:本文主要内容来自《深入探索C++对象模型》

对象模型

简单对象模型

第一种模型十分简单,每个对象就是一个表格,其中每个slot按成员的声明顺序指向对应的成员,包括成员函数与成员变量。

即对于下面的类:

1
2
3
4
5
6
7
8
struct Base {
Base();
void F();
int64_t x;
virtual int G() const;
int32_t y;
virtual void H();
};

它的一个对象为:

one slot per member

这个模型是为了尽量降低C++编译器的设计复杂度,这样我们不需要知道每个成员的大小,只要知道成员的数量,就能计算出对象本身需要占的空间了。每个成员都有着固定的偏移量,因此如果要实现多态,只要改变这个slot对应的函数地址即可。实际我们可以看到,这个模型下每个函数都可以是虚函数,即派生类可以改写基类的任何函数。

它的缺点也很明显:不与C的struct兼容;访问成员需要至少一次间接寻址,开销大。

没有哪个编译器真的采用了这个模型,但它的思想,即每个成员对应一个slot,被吸收到了“指向成员的指针”中。

双表格对象模型

第二种模型使用了两个表格,一个对应成员函数,一个对应成员变量,而对象内则只有指向这两个表格的指针。

对于上面的Base类,新的对象模型为:

double_table

这个模型的好处是令所有对象都有着相同的大小和表现形式。它也是一种停留在理论中的模型,但“函数表格”这一思想却启发了后面的虚表模型。

虚表对象模型

Stroustrup在设计C++时的一个理念就是,让用户不使用的特性零开销。C++的class就体现了这一点。

从演化路径来看,从C的struct到C++的class,大致过程为:

纯数据的结构体 -> 数据+操作的抽象数据类型 -> 能表现多态的类型。

参考之前的文章,我们可以看到:

  • 纯数据的结构体,对应C++中的标准布局类,其相比C的struct没有任何额外开销。
  • 抽象数据类型(ADT),对应C++中的无虚函数的class,其成员变量均有着固定的偏移,与纯结构体相比无额外开销;其成员函数不占用对象本身体积,且调用一个成员函数与调用一个全局函数相比也无额外开销。

现在到了最后一种,当class需要能支持多态,我们该如何设计,来保证上面这两种使用方式不受影响?

Stroustrup选择了一种折衷的方案,即:

  • 每个有虚函数的类型对应一个表格,称为虚表,其中每个slot对应一个虚函数的实际地址。另外虚表的第0个slot指向了这个类型的type_info,用于RTTI。
  • 有虚函数的对象内会增加一个指向虚表的指针,这样在运行时可以通过虚表跳转来实现多态。

对于上面的Base类,虚表对象模型为:

vtpr

当我们不向class中增加虚函数时,编译器不会生成虚表,也不会向对象内增加一个虚表指针,一切都和原来一样。当我们加入虚函数,编译器才会为了这种运行时特性而做上述工作。

之所以不把虚表直接放到对象中,是为了避免对象体积太大,因此我们宁愿多一次虚表指针的跳转。

这个模型下每个成员变量就在对象中,因此在定义类时我们要能看到每个成员变量的布局,知道它的大小,因此无论哪个成员变量发生了变化,我们都要重新编译、链接。这是为了运行效率而付出的代价。

标准中并没有规定编译器一定要这么实现,但目前几乎所有编译器都采用了这种虚表模型,且几乎都选择了把虚表指针放到对象头部(而CFront则放到尾部)。

虚表

以下内容参考GCC的实现,部分脑补,总之理论上是个可以工作的模型。

每个有虚函数的类都对应着一个单独的虚表,而这个类的所有对象中只有指向它的指针。

非派生类型的虚表长度为N+2,其中N为虚函数的个数,按虚函数声明顺序对应,另外的2则分别为第0个位置的type_info*,和最后一个位置的NULL

派生类型的虚表长度为S0+S1+…+N+1,其中S0、S1等分别为其第0个基类、第1个基类等的虚表长度,N为该派生类型自己增加的虚函数数量,1是最后一个位置的NULL(如果N不为0的话)。按上一条,S0、S1的长度是N0+2、N1+2,已经包含了type_info*NULL,因此派生类的虚表中有K个type_info*,且都指向派生类自己的type_info,这里K是其基类数量。

因此派生类的虚表取不同的偏移,就可以得到与其某个基类完全兼容的虚表,但其中每个slot指向的函数则可能是派生类自己的实现。

单继承下的对象模型

从前文中我们知道派生类对象中会有一个基类的子对象,而标准规定了这个基类子对象要“有其完整原样性”,即与一个独立的基类对象有着完全相同的性质。

编译器要首先保证这一点,再去安排派生类对象自己的成员。标准未规定基类子对象在派生类对象中该处于什么位置,但几乎所有编译器都将基类子对象放到了派生类对象的头部。

无虚函数

当基类与派生类均无虚函数时,也就意味着派生类对象中不需要有虚表。此时的派生类对象的内存布局见为struct添加一个无虚函数的非虚继承基类

这种继承有一个很不一样的地方:它没有产生多态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Base {
void Print() const {
cout << "Base" << endl;
}
};

struct Derived {
void Print() const {
cout << "Derived" << endl;
}
};

void Func(const Base& b) {
b.Print();
}

这个例子中,当我们在Func中调用b.Print()时,编译器知道Base::Print不是虚函数,也就意味着它不可能被任何派生类改写,因此编译器会直接将其绑定到Base::Print上,即Func的运行时行为在编译时已经确定了。

基类有虚函数

当基类有虚函数时,意味着派生类也有虚函数,即派生类对象与其中的基类子对象都需要有一个虚表指针,且要指向正确的虚表。

前面我们介绍虚表的时候提到,对派生类的虚表加上不同的偏移量,就能得到与其每个基类虚表完全兼容的虚表,其中第0个基类的偏移量就是0。因此单继承下,派生类对象中也不需要有多个虚表指针,只要头部放置一个虚表指针,就可以同时满足基类子对象与派生类对象的需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct Base {
int64_t x;
int32_t y;

virtual ~Base() {}
virtual void F() {}
};

struct Derived: public Base {
int32_t z;

~Derived() override {}
};

int main() {
Derived d;
Base& b = d;
printf("sizeof(Base):%d sizeof(Derived):%d &d:%x &b:%x\n", sizeof(Base), sizeof(Derived), &d, &b);
}
1
sizeof(Base):24 sizeof(Derived):24 &d:edcc4968 &b:edcc4968

此时Derived对象长成这样:

single

可以看到:

  • 基类子对象的偏移确实是0,说明它与派生类对象共享了虚表指针。
  • 基类子对象与派生类自己的成员之间没有加padding,这与标准布局差别很大,更紧凑了。实际上标准没有对非标准布局有任何明确规定,且对于有虚函数的类型,直接bitwise操作本身就是未定义行为,因此编译器就可以自由选择一种比较紧凑的布局,而不需要担心我们直接操作基类子对象时把派生类的成员变量给破坏了。

基类无虚函数,派生类有虚函数

如果基类没有虚函数,那么基类子对象就不需要有虚表指针;派生类有虚函数,那么派生类对象就需要有虚表指针。因此派生类对象内还是需要一个虚表指针。

GCC的实现是仍然把虚表指针放到派生类对象头部,而基类子对象在其后,此时基类子对象有一个指针的偏移。

1
2
3
4
5
6
7
8
9
10
struct Base {
int64_t x;
int32_t y;
};

struct Derived: public Base {
int32_t z;

virtual void F() {}
};
1
sizeof(Base):16 sizeof(Derived):32 &d:e1855960 &b:e1855968

此时Derived对象长成这样:
base_non_virtual

可以看到:

  • 基类子对象的偏移量为8。
  • 基类子对象后加了padding。此时基类是平凡类,是有可能被人直接以bitwise的方式操作,不加padding就会有危险。

多继承下的多对象模型

当派生类有多个基类时,每个基类自身可能有虚函数,可能没有。对于有虚函数的基类,派生类对象需要为其准备一个虚表指针。对于没有虚函数的基类,则不需要有虚表指针。

如果第0个基类是有虚函数的,那么派生类对象就可以与其共享虚表指针。因此GCC会将其第一个有虚函数的基类子对象放到派生类对象的头部,从而节省一个虚表指针。

而后面的基类则因为其虚表在派生类虚表中的偏移量不为0,无法共享虚表指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Base1 {
int64_t x;
int32_t y;

virtual void F() {}
};

struct Base2 {
int32_t z;
};

struct Derived: public Base1, public Base2 {
int32_t r;
};
1
sizeof(Base1):24 sizeof(Base2):4 sizeof(Derived):32 &d:e5fe3960 &b1:e5fe3960 &b2:e5fe3974

即使我们把public Base1, public Base2换成public Base2, public Base1,结果也没有任何变化。

此时Derived对象长成这样:

而当我们为Base2也添加一个虚函数:

1
2
3
4
5
6
7
8
9
struct Base2 {
int32_t z;
virtual void G() {}
};

struct Derived: public Base1, public Base2 {
int32_t r;
void G() override {}
};

结果为:

1
sizeof(Base1):24 sizeof(Base2):16 sizeof(Derived):40 &d:e2c5d958 &b1:e2c5d958 &b2:e2c5d970

此时Derived对象长成这样:

可以看到:

  • 每个基类子对象都有自己的虚表指针。其中第0个基类子对象的虚表指针与派生类对象本身是共享的。
  • 但两个虚表指针实际都指向派生类自己的虚表,只不过指向的位置不同。
  • 除了第0个基类子对象,其它基类子对象的偏移量都不是0。

指向成员的指针

C++中有一类指针比较特殊,它们是指向类型成员的指针,比如上例中的&Derived::x&Derived::G等,它们的类型分别是int64_t Base1::*void (Derived::*)()。这些指针是不能单独使用的,必须要通过一个对应类型的对象来解引用,例如:

1
2
3
4
5
6
7
8
Derived d;
d.x = 1;

auto px = &Derived::x;
d.*px = 2;

auto pf = &Derived::F;
int ret = d.*pf();

这里我们已经能感觉到它们的不一样了。

指向成员的指针,真的是指针吗?

是指针,但与普通的指针不一样:

  • 不能转换为void*intptr_t等类型。
  • 与普通指针的大小不一定相同,比如在我的环境(64位clang)下,&Derived::x是8字节,而&Derived::F则是16字节。
  • 其值不一定表示地址。

对于第三条,大致有以下规则:

  • 指向成员变量的指针,其值为该变量在对象内的偏移量,比如&Derived::x就是8,而&Base1::x则是0,这样我们能通过一个对象直接寻址到这个变量。
  • 指向非虚成员函数的指针,其大小仍是16字节(我的环境中),但其值真的是这个函数的入口地址,而不是偏移量。
  • 指向虚的成员函数的指针,其值是该函数在该类型的虚表中的偏移量。我们知道虚表的第0位不是虚函数,因此任何指向虚函数的合法指针都不可能是0,通过这一点我们也保证了,如果一个指向虚函数的成员指针为0,那么它一定是空指针。

实际上指向成员函数的指针占两个普通指针的长度,其中就包含了一些辅助信息,来帮助我们在运行时无论遇到虚函数指针还是非虚函数指针,都能正确跳转。

static_castdynamic_castreinterpret_cast

对于基类和派生类,我们有两种cast,分别是down-cast与up-cast,即基类->派生类和派生类->基类。

up-cast

up-cast通常不需要我们显式调用,因为这就是多态正常的使用方式:

1
2
3
4
5
6
7
8
void Func(Base& b) {
b.F();
}

int main() {
Derived d;
Func(d);
}

这里我们把Derived&传给Func,后者看到的却是一个Base&,这里就是发生了up-cast,也是一次隐式转换。如果在某个地方,我们要显式做up-cast,就要使用static_cast了。

重点来了:当编译器做up-cast时,它会根据基类子对象在派生类对象中的偏移量,修改对应指针的值。即当代码里写

1
Base* pb = pd;

时,实际发生的是(假设翻译成C):

1
2
void* p = pd;
Base* pb = (Base*)(p == 0? 0: p + offset);

因此每次up-cast都会有一次分支。而对于基类无虚函数派生类有虚函数,以及多基类场景下,我们都能看到地址发生了变化。

C++标准保证了即使我们使用C风格的转换(即(Base*)pd),编译器也会在其上进行正确的偏移。

而如果我们使用interpret_cast就得不到正确的结果了:

1
2
3
4
Derived d;
Base2* x = &d;
Base2* y = reinterpret_cast<Base2*>(&d);
printf("x:%p y:%p\n", x, y);
1
x:0x7ffee7990968 y:0x7ffee7990950

在我的环境中,这里还有个warning,提醒我换成static_cast

因此结论是:如果要做up-cast,一定不能用reinterpret_cast,要用static_cast,最差也要用C风格的转换。

down-cast

而down-cast就是基类指针转派生类指针。这里正确的做法是使用dynamic_cast,它会做以下事情:

  • 通过基类指针找到其虚表。
  • 从虚表的第0位找到type_info*
  • 对比type_info*与目标类型,如果无法转换,则返回nullptr
  • 根据基类子对象在派生类对象中的偏移,计算出派生类指针并返回。

而有些人会使用static_cast或C风格的转换来做down-cast。它们的问题都在于:不会做前三步的检查,只会做最后一步。

这就导致了,如果转换失败,dynamic_cast会返回nullptr,而static_cast或C风格转换则只会返回一个看似正确地减去了偏移量,实际指向了不知道哪里的派生类指针。

当然,reinterpret_cast就更不对了:它连偏移量都不会算。

警惕Slicing

为struct添加虚成员函数中我们提到,只有通过引用或指针调用虚函数,编译器才会走虚表,才会有多态。实际上,为了与C兼容,保证运行效率,标准规定了这一点。因此直接操作对象时,我们只能得到确定的结果,而不是预期中的运行时多态。

这里有一个陷阱:当我们用派生类对象去赋值或初始化一个基类对象时,派生类的信息会被抹掉,最终我们仅仅得到一个基类对象。这种现象叫Slicing。

1
2
3
Derived d;
Base2 b2 = d;
b2.G();

也许我们预期b2.G()会调用Derived::G,但实际此时b2完完全全就是Base2的对象,因此它只会调用Base2::G

这么做的原因是,b2是一个栈上对象,给它分配的空间就只有sizeof(Base2)这么多,因此它只能是一个Base2对象,而无法是派生类的对象。

这也是一个函数传递要传指针或引用的理由(除了开销与启用多态外),避免Slicing。

原文地址

在售出1000万台设备,实际上是诸如传感器和显示器这样无数不同的应用和辅助设备后,可以说树莓派不仅仅是取得了成功,它还成为了一种程序员最喜爱的嵌入式实验平台。像是Pi zero这样的产品也在成为创造硬件产品的平台,且不会引入设计、构建、为车载设备写软件等方面的风险和开销。

同样地,我也认同Redis是一个程序员乐于去冒险、实验、构建新事物的平台。而且,能用于嵌入式/物联网应用的设备,通常会有暂时或长期存储数据的需求,像是从传感器接收到的数据,需要在这台设备上运算的数据,或是要发往远程服务器的数据。Redis正在加入一种Stream数据类型,非常适合流式数据和时间序列存储,撰写本文时(2017年初)这个特性快要完成了,后续工作会在接下来几周内开始。Redis现存的数据结构,以及新增的Stream类型,以及它较小的内存使用,以及它即使在小型硬件(低功耗)上也能提供相当不错的性能,都让Redis看起来非常适合应用在树莓派,进而是其它小型ARM设备上。中间缺失的部分也很明显:在树莓派上把Redis跑起来。

树莓派的一个很酷的特点就是,它的开发环境不像过去的嵌入式系统那样,它上面跑的就是正常的Linux,还包括各种Debian系的工具。简单地说在树莓派上适配Redis不算很困难。Linux程序移植到树莓派上最常见的问题就是性能或内存占用不匹配,但在Redis上这不是问题,因为它本身就被设计为:空实例只占用1MB内存,且查询请求会走内存,因此它足够快,也不会给闪存太高的压力,而且在需要持久化时,它只会用AOF(Append Only File)。但树莓派上用的是ARM处理器,意味着我们要小心处理未对齐的内存访问。

本文会展示我为了让Redis能愉快地跑在树莓派上都做了什么,我会试着给出一个如何应对那些不能透明地处理非对齐内存访问的平台上(不像x86)的概述。

阅读全文 »

注:本节不讨论union

Scalar Type

第一个概念,Scalar Type,即标量类型。

所谓标量,就是一个数字,而标量类型,就是可以表示为一个数字的类型。

C++的标量类型为:

  • 各种整数/浮点类型,如int8_tuint32_tcharfloat等,可满足std::is_arithmetic<T>为true。
  • 枚举类型,可满足std::is_enum<T>为true。
  • 各种指针类型,包括std::nullptr_t,可满足std::is_pointer<T>std::is_member_pointer<T>为true。

以上类型都属于标量类型,都可满足std::is_scalar<T>::value为true。

Aggregate Type

第二个概念,Aggregate Type,即聚合类型。

所谓聚合类型,就是可以使用= {v1, v2, v3}这样语法(注意不是C++11的std::initializer_list特性)进行初始化或赋值的类型,对应C的数组和struct。

C++的聚合类型为:

  • 所有数组类型。
  • 满足以下条件的类(包括classstruct):
    • 所有非静态成员变量的访问权限都是public
    • 没有用户自定义的构造函数(但允许使用= default来显式使用编译器合成的构造函数,或使用= delete来显式禁止某种构造函数)。
    • 没有基类(C++17后允许有public的非虚基类)。
    • 没有虚函数。
    • 成员变量没有默认初始化式(不在构造函数里那种)(C++11新增,但似乎C++14又去掉了此限制)。

根据上面的定义,聚合类型还有下面的几个特点:

  • 不要求其所有非静态成员变量均为聚合类型。
  • 对静态成员没有任何限制。
  • 只对构造函数有限制,对析构函数、赋值函数等无限制。
  • 非聚合类型的数组也是聚合类型。

当我们写

1
Type a[m] = {b0, b1, ..., bn-1};

时:

  • 若m == n,则会发生b0到a[0]、b1到a[1]等等n次复制初始化。
  • 若m < n,则报错。
  • 若m > n,则a[0]-a[n-1]发生复制初始化,而a[n]-a[m-1]则发生默认初始化。
  • 若m为空,则a的长度会被设定为n,同样发生n次复制初始化。

对于下面的聚合类型

1
2
3
4
5
struct S {
TypeA a;
TypeB b;
TypeC c;
};

当我们写

1
S s = {a', b', c'};

时:

  • {}内元素数量与S中非静态成员变量数量相等,会按a’->a、b’->b、c’->c的方式进行复制初始化。
  • {}内元素数量更多,则报错。
  • S中非静态成员变量数量更多,则后面的成员发生默认初始化。

在初始化时:

  • 若发生复制初始化,则会调用相应类型的复制构造函数或赋值函数。
  • 若列表中某项为表达式,则复制/赋值时允许发生隐式转换(C++11开始要求不能是narrow转换)。
  • 若列表中某项本身也是个{}列表,则要求对应的数组元素/非静态成员变量也是聚合类型,尝试递归聚合赋值。
  • 标量类型的默认初始化会将其初始化为0、0.0、false等。
  • 引用类型的默认初始化会报错。

在对聚合类型(非数组)做列表赋值时,我们还可以指定成员的名字,如:

1
2
3
4
5
6
7
struct A {
int x;
int y;
int z;
};

A a = {.x = 1, .z = 2};

它有以下特点:

  • 列表中名字顺序必须符合成员顺序,即{.z = 2, .x = 1}是不行的。(注意,C允许乱序,还允许其它多种初始化方式,但C++不允许)
  • 列表中元素数量可以少于成员数量,未在列表中出现的成员发生默认初始化,即上例中a.y为0。

Trivial Type

第三个概念,Trivial Type,即平凡类型。

所谓平凡类型,可以认为是有bitwise语义的类型,即可以直接按字节复制的类型。C中的所有类型都是Trivial Type。

Trivial Type有两个标准:

  • 能trivial静态构造,即要有一个trivial的默认构造函数。
  • 能trivial拷贝,即满足Trivial Copyable标准。

Trivial Copyable类型即是满足std::is_trivially_copyable<T>::value为true的类型,它要求:

  • 所有复制构造、赋值函数要么是trivial的,要么是deleted。
  • 所有移动构造、赋值函数要么是trivial的,要么是deleted。
  • 至少有一个非deleted复制或移动的构造或赋值函数。
  • 析构函数为trivial,且非deleted。

构造函数、析构函数、复制构造/赋值函数、移动构造/赋值函数的trivial是指:

  • 满足bitwise语义。
  • 要么是编译器隐式合成的版本。
  • 要么通过= default显式使用编译器的合成版本。

以上条件也就意味着一个Trivial Type:

  • 不能有虚函数(会导致构造函数等失去bitwise语义)。
  • 不能有虚基类(同上)。
  • 如果有基类,基类也要是Trivial Type。
  • 不能有自定义的构造、析构、复制、移动函数。
  • 不能有非Trivial Type类型的非静态成员变量。

标量类型、Trivial Type的数组也是Trivial Type。

Trivial Type是用来区分那些可以像C一样通过memsetmemcpy等函数直接构造和复制的类型,C++11中增加了std::is_trivial模板来判断一个类型是否是trivial的。

注意,Trivial Type还有以下特点:

  • 不限制成员变量的访问限制,即publicprotectedprivate都可以。
  • 只要求默认构造函数是trivial的,对其它构造函数没有要求。

Standard Layout Type

第四个概念,Standard Layout Type,即标准布局类型。

标准布局的目的是定义一种与C兼容的内存布局,满足标准布局的类型即为标准布局类型,Standard Layout Type。

关于不同C++类型的内存布局,可以见上一篇文章C++对象模型(二)struct/class的内存布局

C++的Standard Layout Type要求:

  • 所有非静态成员变量有着相同的访问权限。

  • 没有虚函数或虚基类。

  • 没有引用类型的非静态成员变量。

  • 所有基类和非静态成员变量本身也是Standard Layout Type。

  • 该类型与其所有基类中,最多只能有一个类型有非静态成员变量(其它类型都需要是空类型),即所有非静态成员变量都在一个类型中。

  • 第一个非静态成员变量(包括继承自基类的成员)其类型不能与任一空基类相同(影响空基类优化)。

  • 该类型的继承树中同一类型不能出现多次。

    例子:

    1
    2
    3
    4
    struct Q {};
    struct S: Q {};
    struct T: Q {};
    struct U: S, T {};

    U的继承树中Q出现了两次,则QST都是Standard Layout Type,但U不是。

相同访问权限的原因:标准只规定了同一个section内成员的顺序,未规定不同section之间的顺序,因此若非静态成员变量分布在不同section下,无法给出一个确定的布局。

所有非静态成员变量都在一个类型中的原因:标准未规定基类子对象的位置,因此不同基类、或基类与子类的非静态成员变量间的顺序是未定义的。

没有虚函数或虚基类的原因:虚函数和虚基类会影响类的内存布局,但标准未规定其实现方式,因此有虚函数或虚基类的类型无法给出一个确定的布局。

第一个非静态成员变量不能与空基类类型相同的原因:标准规定同时存在的两个变量不能有相同地址,若应用空基类优化,则第一个非静态成员变量的地址与对象地址相同,也与所有空基类地址相同,若其中有相同类型,则导致该地址同时对应了多个变量。

C++11新增了std::is_standard_layout来判断一个类型是不是Standard Layout Type。

标准布局

C++的标准布局实际就是C中struct的布局,对于一个标准布局类的对象:

  • 其本身的地址与其所有基类子对象的地址相同,即基类子对象的地址无偏移。
  • 其各个非静态成员变量的位置按声明顺序从对象地址开始由低到高排列。
  • 其第一个非静态成员变量的地址与对象地址相同。
  • 其各个非静态成员变量的地址均满足对齐要求。

POD Type

最后一个概念,POD Type,即Plain Old Data Type,即可导出,可跨语言使用的类型(通常也意味着与C二进制兼容)。

一个POD类型为:

  • 标量类型。
  • 满足以下条件的自定义类型:
    • C++11之前:
      • 聚合类型。
      • 没有非POD类型的非静态成员变量。
      • 没有引用类型的非静态成员变量。
      • 没有自定义的构造函数或析构函数。
    • C++11之后:
      • 是平凡类。
      • 是标准布局类。
      • 没有非POD类型的非静态成员变量。
  • POD类型的数组。

可以看到POD的标准在C++11前后发生了很大的变化。C++11里放宽了对POD的限制,且根据这些限制的目的,提出了平凡类和标准布局类这两个更清晰的概念。在C++20后POD这个概念本身都会被去掉,而是在不同场合直接使用平凡类、标准布局类等概念。

一个类型可以只是平凡类或只是标准布局类:

  • 如果是平凡类,则意味着它可以直接通过memcpymemset等函数来操作。
  • 如果是标准布局类,则意味着它的布局是确定的,可以与其它语言交互。

可以用is_pod来判断一个类型是不是POD类型。

POD的用途

平凡类的用途:

  • 平凡类的对象可以与字节流之间安全转换,即:
    • 若要将对象转为字节流,直接取其地址即可。
    • 若要将字节流转为对象,直接将该地址cast为对象指针即可。
    • 直接通过复制字节的方式复制对象。
  • 安全的静态初始化。
    • C++11的thread_local变量可以是非平凡类型,但在某些编译器下会有比较大的性能开销。gcc扩展的__thread只能使用POD类型。

标准布局类的用途:

  • 跨进程、跨语言使用。

  • 运行环境:x86-64。
  • 编译器:gcc4.8.5。
  • 编译选项:-O2。
  • 语言标准:以c++98为主,兼顾c++11/14。

c++中的struct与c中的struct

第一个问题:c++中的struct与c中的struct相同吗?

答案是,有时相同,有时不同。

像c一样定义struct

如果我们简单的按照c的方式定义一个struct,如c代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct {
int8_t a;
int64_t b;
int32_t c;
} S;

int main() {
S ss = {1, 2, 3};
S s = ss;
printf(
"size:%d &a-&s:%d, &b-&s:%d &c-&s:%d\n",
sizeof(s),
(char*)&s.a - (char*)&s,
(char*)&s.b - (char*)&s,
(char*)&s.c - (char*)&s);
return 0;
}

和c++代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct S {
int8_t a;
int64_t b;
int32_t c;
};

int main() {
S ss = {1, 2, 3};
S s = ss;
printf(
"size:%d &a-&s:%d, &b-&s:%d &c-&s:%d\n",
sizeof(s),
(char*)&s.a - (char*)&s,
(char*)&s.b - (char*)&s,
(char*)&s.c - (char*)&s);
return 0;
}

分别用gcc和g++编译上面两段代码,结果是:

1
2
3
size:24 &a-&s:0, &b-&s:8 &c-&s:16

size:24 &a-&s:0, &b-&s:8 &c-&s:16

完全一样对不对?再看下汇编指令:

1
2
3
4
5
6
7
8
9
10
400440:   48 83 ec 08             sub    $0x8,%rsp
400444: 41 b8 10 00 00 00 mov $0x10,%r8d
40044a: b9 08 00 00 00 mov $0x8,%ecx
40044f: 31 d2 xor %edx,%edx
400451: be 18 00 00 00 mov $0x18,%esi
400456: bf f0 05 40 00 mov $0x4005f0,%edi
40045b: 31 c0 xor %eax,%eax
40045d: e8 ae ff ff ff callq 400410 <printf@plt>
400462: 31 c0 xor %eax,%eax
400464: 48 83 c4 08 add $0x8,%rsp

1
2
3
4
5
6
7
8
9
10
400500:   48 83 ec 08             sub    $0x8,%rsp
400504: 41 b8 10 00 00 00 mov $0x10,%r8d
40050a: b9 08 00 00 00 mov $0x8,%ecx
40050f: 31 d2 xor %edx,%edx
400511: be 18 00 00 00 mov $0x18,%esi
400516: bf b0 06 40 00 mov $0x4006b0,%edi
40051b: 31 c0 xor %eax,%eax
40051d: e8 ae ff ff ff callq 4004d0 <printf@plt>
400522: 31 c0 xor %eax,%eax
400524: 48 83 c4 08 add $0x8,%rsp

是不是也完全一样?当我们在c++里像c一样定义struct时,编译器会给我们一个与c的struct完全相同的结构。

为struct添加静态成员

我们为S添加一个静态成员变量与静态成员函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct S {
int8_t a;
int64_t b;
int32_t c;

static int d;

static void Print() {
printf("%d\n", d);
}
};

int S::d = 0;

仍然运行上面的main函数,结果就不贴了,我们会发现与c的struct仍然相同。这说明:

  1. 静态成员变量不会影响struct布局。换句话说,静态成员变量不存在于对象内部。
  2. 静态成员方法也不会影响struct布局,即也不存在于对象内部。

为struct添加非静态非虚成员函数

为struct添加构造、析构、复制、移动函数

假设我们向S中添加上述函数,上面的结论会有什么变化?

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
struct S {
int8_t a;
int64_t b;
int32_t c;

S(int8_t x, int64_t y, int32_t z) {
a = x;
b = y;
c = z;
}

~S() {
a = 0;
b = 0;
c = 0;
}

S(const S& s) {
a = s.a;
b = s.b;
c = s.c;
}

S(S&& s) {
a = s.a;
b = s.b;
c = s.c;
}
};

运行结果没有任何变化,汇编指令也完全相同。这说明struct加上构造函数并没有改变它的布局,即:

  • 以上函数均不存在于对象内部。
  • 以上函数均未带来额外开销。

但我们不能说这些函数对struct没有任何影响,后面会讲到,当我们添加了这些函数后:

  • 这个struct不再满足POD的定义,即不再有bitwise语义。
  • 如果上述函数未被定义为内联(inline)函数,则其会带来额外开销。

为struct添加普通非虚函数

我们比较以下两种写法。写法一:

1
2
3
4
5
6
7
8
9
10
11
12
struct S {
int8_t a;
int64_t b;
int32_t c;
};

void Func(S* s) {
s->a += 1;
s->b -= 1;
s->c *= 2;
printf("a:%d b:%ld c:%d\n", s->a, s->b, s->c);
}

与写法二:

1
2
3
4
5
6
7
8
9
10
11
12
struct S {
int8_t a;
int64_t b;
int32_t c;

void Func() {
a += 1;
b -= 1;
c *= 2;
printf("a:%d b:%ld c:%d\n", a, b, c);
}
};

对应的main函数为:

1
2
3
4
5
6
7
8
9
10
int main() {
S s = {1, 2, 3};
s.Func(); // or Func(&s);
printf(
"size:%d &a-&s:%d, &b-&s:%d &c-&s:%d\n",
sizeof(s),
(char*)&s.a - (char*)&s,
(char*)&s.b - (char*)&s,
(char*)&s.c - (char*)&s);
}

写法一的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
a:2 b:1 c:6
size:24 &a-&s:0, &b-&s:8 &c-&s:16

400500: 48 83 ec 28 sub $0x28,%rsp
400504: 48 89 e7 mov %rsp,%rdi
400507: c6 04 24 01 movb $0x1,(%rsp)
40050b: 48 c7 44 24 08 02 00 movq $0x2,0x8(%rsp)
400512: 00 00
400514: c7 44 24 10 03 00 00 movl $0x3,0x10(%rsp)
40051b: 00
40051c: e8 1f 01 00 00 callq 400640 <_Z4FuncP1S>
400521: 41 b8 10 00 00 00 mov $0x10,%r8d
400527: b9 08 00 00 00 mov $0x8,%ecx
40052c: 31 d2 xor %edx,%edx
40052e: be 18 00 00 00 mov $0x18,%esi
400533: bf 18 07 40 00 mov $0x400718,%edi
400538: 31 c0 xor %eax,%eax
40053a: e8 91 ff ff ff callq 4004d0 <printf@plt>
40053f: 31 c0 xor %eax,%eax
400541: 48 83 c4 28 add $0x28,%rsp

写法二的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
a:2 b:1 c:6
size:24 &a-&s:0, &b-&s:8 &c-&s:16

400500: 48 83 ec 08 sub $0x8,%rsp
400504: b9 06 00 00 00 mov $0x6,%ecx
400509: ba 01 00 00 00 mov $0x1,%edx
40050e: be 02 00 00 00 mov $0x2,%esi
400513: bf c0 06 40 00 mov $0x4006c0,%edi
400518: 31 c0 xor %eax,%eax
40051a: e8 b1 ff ff ff callq 4004d0 <printf@plt>
40051f: 41 b8 10 00 00 00 mov $0x10,%r8d
400525: b9 08 00 00 00 mov $0x8,%ecx
40052a: 31 d2 xor %edx,%edx
40052c: be 18 00 00 00 mov $0x18,%esi
400531: bf d8 06 40 00 mov $0x4006d8,%edi
400536: 31 c0 xor %eax,%eax
400538: e8 93 ff ff ff callq 4004d0 <printf@plt>
40053d: 31 c0 xor %eax,%eax
40053f: 48 83 c4 08 add $0x8,%rsp

对比两种写法的结果,我们发现:

  • 成员布局上,两者相同,即普通的非虚成员函数不存在于对象中,不会占用空间。
  • 汇编指令上,第一种写法调用了Func(S*),而第二种写法完全看不到S::Func,而是直接调用了printf

上面的第二条发现,实际上就是inline的效果。c++标准规定了定义在类(无论是class还是struct)定义中的函数都默认带有inline效果,因此它被编译器直接展开到调用处了。

如果我们给Func(S*)前面加上inline,我们会得到与写法二完全相同的汇编指令(不贴了)。因此结论就是:

  • 普通的非虚成员函数不会占用对象空间,也不会带来额外开销,与对应的非成员函数完全相同。
  • Func(S*)等效于S::Func()

针对上面的第二点,实际上S::Func()会被编译器变成一个非成员函数,类似为S_Func(S* const this),而S::Func() const则对应S_Func(const S* const this)

为struct添加虚成员函数

我们将S::Func改为一个虚函数:

1
2
3
4
5
6
7
8
9
10
11
12
struct S {
int8_t a;
int64_t b;
int32_t c;

virtual void Func() {
a += 1;
b -= 1;
c *= 2;
printf("a:%d b:%ld c:%d\n", a, b, c);
}
};

对应的main函数不变,直接运行会报错:

1
2
3
4
5
struct.cpp: In function ‘int main()’:
struct.cpp:18:19: error: in C++98 ‘s’ must be initialized by constructor, not by ‘{...}’
S s = {1, 2, 3};
^
struct.cpp:18:19: error: could not convert ‘{1, 2, 3}’ from ‘<brace-enclosed initializer list>’ to ‘S’

似乎此时struct与c的struct已经不一样了。我们给它加上一个构造函数:

1
2
3
4
5
S::S(int8_t x, int64_t y, int32_t z) {
a = x;
b = y;
c = z;
}

就可以编译过了。先运行前面的main函数(构造那行要改),结果是:

1
size:32 &a-&s:8, &b-&s:16 &c-&s:24

我们发现:

  • 加入虚函数后,对象变大了,说明虚函数占用了一部分对象空间。
  • 对象变大了8字节(实际是虚表指针),且正好在对象的最前面,其它成员变量的位置依次向下8字节。

这是第一个与c的struct布局不同的场景。我们知道虚函数是为了实现运行期多态的,那么就需要有信息来帮助程序在运行期根据对象的不同而选择不同的行为,这种信息就会带来运行期的额外开销。

但调用虚函数真的就会有运行期开销吗?我们分别看一下直接通过对象来调用虚函数,与通过指针或引用调用虚函数的区别。

我们添加三个Test函数,并在main函数中调用它:

1
2
3
4
5
6
7
8
9
10
11
void Test1(S s) {
s.Func();
}

void Test2(S* s) {
s->Func();
}

void Test3(S& s) {
s.Func();
}

对应的汇编指令为:

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
0000000000400710 <_Z5Test11S>:
400710: 0f b6 47 08 movzbl 0x8(%rdi),%eax
400714: 8d 70 01 lea 0x1(%rax),%esi
400717: 48 8b 47 10 mov 0x10(%rdi),%rax
40071b: 40 88 77 08 mov %sil,0x8(%rdi)
40071f: 40 0f be f6 movsbl %sil,%esi
400723: 48 8d 50 ff lea -0x1(%rax),%rdx
400727: 8b 47 18 mov 0x18(%rdi),%eax
40072a: 48 89 57 10 mov %rdx,0x10(%rdi)
40072e: 8d 0c 00 lea (%rax,%rax,1),%ecx
400731: 31 c0 xor %eax,%eax
400733: 89 4f 18 mov %ecx,0x18(%rdi)
400736: bf 20 08 40 00 mov $0x400820,%edi
40073b: e9 20 fe ff ff jmpq 400560 <printf@plt>

0000000000400740 <_Z5Test2P1S>:
400740: 48 8b 07 mov (%rdi),%rax
400743: 48 8b 00 mov (%rax),%rax
400746: ff e0 jmpq *%rax
400748: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1)
40074f: 00

0000000000400750 <_Z5Test3R1S>:
400750: 48 8b 07 mov (%rdi),%rax
400753: 48 8b 00 mov (%rax),%rax
400756: ff e0 jmpq *%rax
400758: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1)
40075f: 00

我们发现:

  • Test1Func被展开了,看不到直接的Func调用,这点与调用一个非虚函数的行为相同。
  • Test2中通过间接跳转(jmpq)调用了Func,方法是先取出s(%rdi)的前8字节到%rax,再从%rax取出前8字节放到%rax,这就是Func的地址,之后就是一次间接跳转。
  • Test3Test2完全相同。

结论:

  • 通过一个对象调用虚函数时,编译器没有采用运行期多态,而是直接像调用一个非虚函数一样,没有运行期开销。
  • 通过指针调用虚函数时,有运行期开销,即需要一次间接跳转,此时虚函数无法展开。
  • 引用与指针此处无区别,引用就是一种语法糖。

为struct添加一个非虚继承的基类

为struct添加一个无虚函数的非虚继承基类

我们修改一下S

1
2
3
4
5
6
7
8
struct Base {
int32_t ba;
};
struct S: public Base {
int8_t a;
int64_t b;
int32_t c;
};

运行前面的main函数,结果为:

1
size:24 &a-&s:4, &b-&s:8 &c-&s:16

此时S的布局可以认为是:

1
2
3
4
5
6
struct S {
int32_t ba;
int8_t a;
int64_t b;
int32_t c;
};

1
2
3
4
5
6
struct S {
Base base;
int8_t a;
int64_t b;
int32_t c;
};

两者的区别在于,前者是基类的所有成员都可以被当作子类的成员,而后者是基类子对象就是子类的第一个成员。

到底是哪种呢?当基类为:

1
2
3
4
struct Base {
int32_t ba;
int8_t bb;
};

时,如果按前者,Base::bbS::a之间应该没有padding,即此时S的大小仍然是24;如果按后者,Base的alignment为8,此时Base::bb后面会有padding,S的大小应该是32。我们试一下,结果为:

1
size:32 &a-&s:8, &b-&s:16 &c-&s:24

说明:基类子对象可以被当作子类对象的第一个成员,且保持自己的alignment和padding。

为struct添加第二个非虚基类

我们再加一个基类:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Base {
int32_t ba;
int8_t bb;
};
struct Base2 {
int32_t ca;
int8_t cb;
};
struct S: public Base, public Base2 {
int8_t a;
int64_t b;
int32_t c;
};

根据上节的结论,我们可以认为Base2也是S的一个成员,且应排列在Base后面,S的大小应该是40。实验结果为:

1
size:40 &a-&s:16, &b-&s:24 &c-&s:32

证实了我们的猜测。

为struct添加空的基类

所谓空类型,指:

  • 没有任何非静态成员变量。
  • 没有任何虚函数。
  • 没有任何虚基类。
  • 其上没有基类,或只有空基类。

当我们给S添加一个空类型的基类时,如:

1
2
3
4
5
6
struct Base {
};
struct S: public Base {
int64_t b; // 注意该场景中没有成员a
int32_t c;
};

根据之前的结论,S应该相当于:

1
2
3
4
5
struct S {
Base base;
int64_t b;
int32_t c;
};

我们知道,c++中任何类型的size都至少是1,这是为了避免不同变量对应相同的内存地址。那么base的size就是1,S的size就应该是24。实际上呢?

1
size:16 &b-&s:0 &c-&s:8

居然是16!这就是c++的空基类优化(Empty Base Optimization,EBO),当基类子对象为空时,其不必在子类对象中占据空间,且与子类对象共享相同的地址。这里是一个c的oop无法模拟的点。

为struct添加有虚函数的非虚继承基类

基类无成员变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct Base {
virtual ~Base() {}
};
struct S: public Base {
int8_t a;
int64_t b;
int32_t c;
};
int main() {
S s;
printf(
"Base:%d S:%d &base-&s:%d &a-&s:%d &b-&s:%d &c-&s:%d\n",
sizeof(Base),
sizeof(S),
(char*)static_cast<Base*>(&s) - (char*)&s,
(char*)&s.a - (char*)&s,
(char*)&s.b - (char*)&s,
(char*)&s.c - (char*)&s);
}

结果:

1
Base:8 S:32 &base-&s:0 &a-&s:8 &b-&s:16 &c-&s:24

结论:

  • 有虚函数的类型,其对象中需要有1个虚表指针来存放运行期信息,不再是空类型,作为基类也没办法应用EBO。
  • 子类对象中不会有2个虚表指针(基类子对象1个,子类对象1个),而是与基类共用1个虚表指针。

基类有成员变量

假设我们给基类添加一个成员变量:

1
2
3
4
5
6
7
8
9
struct Base {
virtual ~Base() {}
int8_t ba;
};
struct S: public Base {
int8_t a;
int64_t b;
int32_t c;
};

根据前文规则,Base大小为16,其alignment为8,S的大小就会是40,且Base::bbS::a之间有padding。但运行结果却是:

1
Base:16 S:32 &base-&s:0 &a-&s:9 &b-&s:16 &c-&s:24

与我们的猜测不符,Base::bbS::a之间没有padding。

后面我们会说到c++有一种标准布局(Standard Layout),这种布局需要与c的struct布局兼容(外加空基类优化)。而当BaseS中加入虚函数后,它们就不再符合标准布局了,编译器就可以应用更紧凑的布局了。

为什么标准布局需要与c的struct布局兼容?因为POD(Plain Old Data)类型首先需要是标准布局类型,而POD类型本身就是为了与c兼容而提出的概念。

为struct添加虚继承基类

注:大多数c++项目都禁止使用虚继承,因此下面的几个场景我们只给输出和大概的结论,不进行更多的探索和解释了。

基类为空类型

1
2
3
4
5
6
7
struct Base {
};
struct S: public virtual Base {
int8_t a;
int64_t b;
int32_t c;
};

输出:

1
Base:1 S:32 &base-&s:0 &a-&s:8 &b-&s:16 &c-&s:24

结论:

  • 虚基类会在子类中占用额外空间(1个指针),位置在子类最前面,此时无法应用EBO。

基类为非空无虚函数类型

1
2
3
4
5
6
7
8
struct Base {
int8_t ba;
};
struct S: public virtual Base {
int8_t a;
int64_t b;
int32_t c;
};

输出:

1
Base:1 S:32 &base-&s:28 &a-&s:8 &b-&s:16 &c-&s:24

结论:

  • 子类对象最前面仍然是1个指针。
  • 此时基类子对象位于子类最后。

基类为非空有虚函数类型

1
2
3
4
5
6
7
8
9
struct Base {
virtual ~Base() {}
int8_t ba;
};
struct S: public virtual Base {
int8_t a;
int64_t b;
int32_t c;
};

输出:

1
Base:16 S:48 &base-&s:32 &a-&s:8 &b-&s:16 &c-&s:24

结论:

  • 此时子类对象的前8字节不再是虚表指针,而是指向虚基类子对象的指针。
  • 基类子对象的前8字节是虚表指针,且其整体位于子类最后一个成员变量的后面。

无虚函数的菱形继承

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Base {
int8_t ba;
};
struct C: public virtual Base {
int32_t ca;
};
struct D: public virtual Base {
int32_t da;
};
struct S: public C, public D {
int64_t b;
int32_t c;
};

输出:

1
Base:1 C:16 D:16 S:48 &Base-&S:44 &C-&S:0 &D-&S:16 &b-&s:32 &c-&s:40

结论:

  • 此时C的子对象与S对象共享一个虚基类指针,而D则自己使用一个虚基类指针。
  • CD依次位于S的前端,而Base依然在最后端。
  • 调用来自虚基类的虚函数时,相比非虚基类的虚函数,要多一次间接跳转:先通过虚基类指针找到虚表指针,再通过虚表指针找到对应函数地址。

有虚函数的菱形继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Base {
virtual ~Base() {}
int8_t ba;
};
struct C: public virtual Base {
int32_t ca;
};
struct D: public virtual Base {
int32_t da;
};
struct S: public C, public D {
int64_t b;
int32_t c;
};

输出:

1
Base:16 C:32 D:32 S:64 &Base-&S:48 &C-&S:0 &D-&S:16 &b-&s:32 &c-&s:40

结论:

  • CD依然位于S的前端,而Base位于后端。
  • CS共享一个虚基类指针,D自己使用一个虚基类指针。
  • CDSBase共享一个虚表指针。

struct与class

第二个问题,c++中的struct和class有什么区别?

答案是,除了默认访问权限不同(struct默认为public,而class默认为private)外,其它完全相同。

上面的例子中,我们把每个struct都换成class,仍然能得到相同的结论。决定对象模型的不是用哪个关键字修饰它,而是它本身的性质,是否有基类,是否有虚函数,是否有虚基类。

然而struct不能用于修饰模板参数类型

但struct却不能用于下面这个场景:

1
2
3
4
template <struct X>
void PrintSize() {
printf("%d\n", sizeof(X));
}

当我们编译时,报错信息为:

1
2
3
struct.cpp:19:18: error: ‘struct X’ is not a valid type for a template non-type parameter
template <struct X>
^

而当我们把struct改成class或typename后,就可以编译成功了。

struct的零额外开销

众所周知,c++的一个核心理念就是保证某个功能对不使用它的用户零额外开销。我们从几方面看一下struct是如何实现零额外开销的。

使用栈上的struct成员

下面两段代码:

1
2
3
4
5
6
7
8
9
int64_t Func(int8_t x, int64_t y, int32_t z) {
int8_t a;
int64_t b;
int32_t c;
a = x;
b = y;
c = z;
return a + b + c;
}

1
2
3
4
5
6
7
8
9
10
11
12
struct S {
int8_t a;
int64_t b;
int32_t c;
};
int64_t Func(int8_t x, int64_t y, int32_t z) {
S s;
s.a = x;
s.b = y;
s.c = z;
return s.a + s.b + s.c;
}

它们对应的汇编指令为:

1
2
3
4
5
6
7
00000000004005b0 <_Z4Funcali>:
4005b0: 48 0f be ff movsbq %dil,%rdi
4005b4: 48 63 d2 movslq %edx,%rdx
4005b7: 48 01 fe add %rdi,%rsi
4005ba: 48 8d 04 16 lea (%rsi,%rdx,1),%rax
4005be: c3 retq
4005bf: 90 nop

1
2
3
4
5
6
7
00000000004005b0 <_Z4Funcali>:
4005b0: 48 0f be ff movsbq %dil,%rdi
4005b4: 48 63 d2 movslq %edx,%rdx
4005b7: 48 01 fe add %rdi,%rsi
4005ba: 48 8d 04 16 lea (%rsi,%rdx,1),%rax
4005be: c3 retq
4005bf: 90 nop

完全相同,说明使用栈上的struct成员,与使用栈上变量完全相同,零额外开销。

传递小struct

下面两段代码:

1
2
3
4
5
6
7
8
void Func(int32_t x, int32_t y) {
printf("x:%d y:%d\n", x, y);
}
int main() {
int32_t x = 1;
int32_t y = 2;
Func(1, 2);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
struct S {
int32_t x;
int32_t y;
};
void Func(S s) {
printf("x:%d y:%d\n", s.x, s.y);
}
int main() {
S s;
s.x = 1;
s.y = 2;
Func(s);
}

对应的汇编指令为:

1
2
3
4
5
6
7
8
9
10
11
0000000000400500 <main>:
400500: 48 83 ec 08 sub $0x8,%rsp
400504: ba 02 00 00 00 mov $0x2,%edx
400509: be 01 00 00 00 mov $0x1,%esi
40050e: bf b0 06 40 00 mov $0x4006b0,%edi
400513: 31 c0 xor %eax,%eax
400515: e8 b6 ff ff ff callq 4004d0 <printf@plt>
40051a: 31 c0 xor %eax,%eax
40051c: 48 83 c4 08 add $0x8,%rsp
400520: c3 retq
400521: 0f 1f 00 nopl (%rax)

1
2
3
4
5
6
7
8
9
10
11
0000000000400500 <main>:
400500: 48 83 ec 08 sub $0x8,%rsp
400504: ba 02 00 00 00 mov $0x2,%edx
400509: be 01 00 00 00 mov $0x1,%esi
40050e: bf c0 06 40 00 mov $0x4006c0,%edi
400513: 31 c0 xor %eax,%eax
400515: e8 b6 ff ff ff callq 4004d0 <printf@plt>
40051a: 31 c0 xor %eax,%eax
40051c: 48 83 c4 08 add $0x8,%rsp
400520: c3 retq
400521: 0f 1f 00 nopl (%rax)

注意此时两个Func函数都被inline掉了,因此我们可以直接对应main的汇编代码。可以看到它们完全相同,也符合上节的结论。

当我们把inline关掉后,先看一下main(看参数是如何传递的):

1
2
3
4
5
0000000000400500 <main>:
400500: 48 83 ec 08 sub $0x8,%rsp
400504: be 02 00 00 00 mov $0x2,%esi
400509: bf 01 00 00 00 mov $0x1,%edi
40050e: e8 fd 00 00 00 callq 400610 <_Z4Funcii>

1
2
3
4
5
0000000000400500 <main>:
400500: 48 83 ec 08 sub $0x8,%rsp
400504: 48 bf 01 00 00 00 02 movabs $0x200000001,%rdi
40050b: 00 00 00
40050e: e8 fd 00 00 00 callq 400610 <_Z4Func1S>

可以看到直接传递一个struct反倒少了一条指令!原因是此时S为8个字节,刚好可以放入一个寄存器中,因此可以一条指令传递过去。而如果分成两个int32_t,则编译器必须用两个寄存器传递,多了一条指令。

再对比一下Func的汇编代码:

1
2
3
4
5
6
0000000000400610 <_Z4Funcii>:
400610: 89 f2 mov %esi,%edx
400612: 31 c0 xor %eax,%eax
400614: 89 fe mov %edi,%esi
400616: bf b0 06 40 00 mov $0x4006b0,%edi
40061b: e9 b0 fe ff ff jmpq 4004d0 <printf@plt>

1
2
3
4
5
6
7
0000000000400610 <_Z4FuncS>:
400610: 48 89 fa mov %rdi,%rdx
400613: 89 fe mov %edi,%esi
400615: 31 c0 xor %eax,%eax
400617: 48 c1 fa 20 sar $0x20,%rdx
40061b: bf c0 06 40 00 mov $0x4006c0,%edi
400620: e9 ab fe ff ff jmpq 4004d0 <printf@plt>

可以看到传递S的版本多了一条sar $0x20,%rdx,这是因为我们用一个寄存器传递了两个值,但在调用printf时还是要把它们分开,因此这里需要先把低4字节放到另一个寄存器里,再把%rdx的内容右移32位,从而得到高4字节的值。

mainFunc加起来,两个版本的汇编指令数量仍然完全相同,区别在于前者传递时多一次赋值,后者运算时多一次右移,可以认为开销相同。