问题

如何理解《Effective C++》第31条将文件间的编译依赖关系降低的方法?

回答
好的,我们来深入聊聊《Effective C++》第31条,关于如何降低文件间的编译依赖关系这个至关重要的话题。这不仅是为了提高编译速度,更是为了构建更易于维护、更灵活的 C++ 系统。

想象一下我们正在开发一个大型 C++ 项目。随着功能的不断增加,我们不可避免地会创建越来越多的头文件(.h/.hpp)和源文件(.cpp)。每个头文件都可能包含其他头文件,而每个源文件都需要 `include` 它所依赖的头文件才能编译。

问题的关键在于:当一个头文件发生改变时,所有 `include` 了这个头文件的源文件都需要重新编译。 如果一个被广泛使用的头文件(比如一个基础的工具类、容器的定义等)发生了微小的改动,那么整个项目可能会触发海量的重新编译,这对于开发效率来说是毁灭性的打击。

所以,《Effective C++》第31条的核心思想就是:尽量减少源文件直接包含头文件的数量,尤其是那些容易发生变化的、或者我们不关心其具体实现的头文件。

下面,我们来拆解几种常用的、也是作者推荐的降低编译依赖的方法,并进行详细的阐述:

1. 使用前向声明 (Forward Declaration) 替代 `include`

这是最核心、最常用的技巧。

什么是前向声明?

前向声明(Forward Declaration)是一种声明,它告诉编译器某个类、结构体、函数或枚举的存在,但并不提供其完整的定义。

例子:

假设我们有 `Person.h` 文件:

```c++
// Person.h
include // 依赖 string

class Person {
public:
Person(const std::string& name);
void setName(const std::string& name);
const std::string& getName() const;

private:
std::string name_;
};
```

以及 `Employee.h` 文件,它需要使用 `Person` 类:

```c++
// Employee.h
include "Person.h" // 依赖 Person.h

class Employee {
public:
Employee(const Person& person, int id);
void setPerson(const Person& person);
int getId() const;

private:
Person person_;
int id_;
};
```

在 `Employee.h` 中,我们为了使用 `Person` 对象,不得不包含 `Person.h`。如果 `Person.h` 发生变化(比如添加了新的成员变量或方法),所有 `include "Person.h"` 的文件(包括 `Employee.h` 以及任何包含 `Employee.h` 的其他文件)都可能需要重新编译。

如何用前向声明解决这个问题?

我们可以将 `Employee.h` 修改为:

```c++
// Employee.h (使用前向声明)
// include "Person.h" // 移除了这里的 include

// 前向声明 Person 类
class Person;

class Employee {
public:
// 注意:这里不能直接使用 Person 的具体类型作为参数或成员变量
// 因为我们不知道 Person 的大小或其成员的布局
// 这种用法需要其他技巧,下面会讲到

private:
// 同样,这里不能直接声明 Person 类型的成员变量
// Person person_;
int id_;
};
```

分析前向声明的限制和应用场景:

你能做什么?
声明指针或引用指向一个类:`Person ptr_person;` 或 `Person& ref_person;`
声明函数参数或返回类型(如果函数不涉及对象的具体操作):`void processPerson(Person& p);`
声明指向成员的指针:`class Person; int Person::p;`

你不能做什么?
声明一个类的对象作为成员变量:`Person person_;` —— 因为编译器需要知道 `Person` 的大小来分配内存。
作为函数的参数或返回值(如果函数需要了解对象的具体成员或调用方法):例如,`Employee(const Person& person, int id);` 这种构造函数接收 `Person` 的引用,需要 `Person` 的完整定义来复制。
继承自某个类:`class Derived : public Person {};` —— 需要知道基类的成员。
使用 `sizeof(Person)`:需要完整的类型信息。

什么时候使用前向声明?

当你的头文件只是需要知道一个类“存在”,但不需要访问它的成员或知道其大小的时候,就应该考虑前向声明。

如何处理前向声明后的实际需求?

如果你的头文件确实需要使用 `Person` 对象,但又想避免 `include "Person.h"` 的依赖,我们可以通过以下几种方式来“延迟”对 `Person.h` 的引入:

只声明指针或智能指针作为成员:
```c++
// Employee.h (使用指针成员)
include // 需要智能指针头文件

class Person; // 前向声明

class Employee {
public:
// 使用智能指针来管理 Person 对象
// 构造函数可能需要 Person.h, 但声明可以使用指针
Employee(const Person& person, int id);
void setPerson(const Person& person); // 这个方法需要 Person.h
const Person getPerson() const; // 返回指针

private:
std::unique_ptr person_ptr_; // 使用智能指针成员
int id_;
};
```
在这个例子中,`Employee.h` 只包含了 ``。`Employee` 类只持有 `Person` 的智能指针,这不需要 `Person` 的完整定义。然而,在 `Employee.cpp` 中,当需要创建 `Person` 对象或调用 `Person` 的方法时,仍然需要 `include "Person.h"`。但这将依赖从 `Employee.h` 转移到了 `Employee.cpp`,效果是显著的。

使用接口(纯虚函数): 如果 `Person` 类是一个抽象基类,提供纯虚函数,那么 `Employee` 只需要一个 `Person` 或 `Person&` 指针,就可以通过指针调用这些纯虚函数,而不需要 `Person` 的完整定义。

通过 Getter/Setter 方法,将对 `Person` 具体定义的依赖推迟到实现文件:
```c++
// Employee.h (推迟依赖)
class Person; // 前向声明

class Employee {
public:
// 构造函数可能还是需要 Person.h,这里是示例简化
Employee(int id);
// Setter 方法,这里接收 Person 的指针或引用,但不复制
void setPersonPtr(const Person person);
// Getter 方法,返回指针
const Person getPersonPtr() const;

private:
const Person person_ptr_; // 只存储指针
int id_;
};
```
在 `Employee.cpp` 中,你会 `include "Person.h"` 来创建 `Person` 对象并赋值给 `person_ptr_`,或者在 `setPersonPtr` 中处理 `Person` 对象。

2. 将具体类型隐藏到实现文件 (.cpp) 中

这是前向声明的应用的延伸。核心思想是:你的头文件越“瘦”,包含的依赖就越少。

成员变量的类型: 如果你有一个类 `Widget`,它包含一个成员变量 `MyComplexType complex_member;`,而 `MyComplexType` 又是一个庞大且经常变动的类,那么 `Widget.h` 就需要 `include "MyComplexType.h"`。
改进方案:
将 `complex_member` 改为指向 `MyComplexType` 的指针:`MyComplexType complex_member_ptr;`。这样 `Widget.h` 只需要前向声明 `MyComplexType`。
或者使用智能指针:`std::unique_ptr complex_member_ptr;`。

函数参数和返回类型:
坏例子:
```c++
// Utils.h
include "BigContainer.h"
BigContainer processData(const BigContainer& data);
```
这里 `Utils.h` 需要 `include "BigContainer.h"`,并且 `processData` 的签名直接暴露了 `BigContainer`。
改进方案 (Pimpl Idiom Pointer to Implementation):
将 `BigContainer` 的具体操作以及可能的 `BigContainer` 的成员变量,都隐藏在一个独立的实现类中,而 `Utils` 类只包含指向这个实现类的指针。

`Utils.h`:
```c++
include // for std::unique_ptr

class UtilsImpl; // 前向声明实现类

class Utils {
public:
Utils();
~Utils(); // 析构函数需要定义,否则unique_ptr无法正确析构

// 声明一个需要使用 BigContainer 的方法
// 但这里的参数类型可以是 void 或者一个更通用的接口
// 或者直接传递一个已经构建好的 BigContainer 的引用/指针
// 这里我们假设传入一个通用的容器类型
void process(const void data_ptr, size_t data_size);

private:
std::unique_ptr pimpl_; // 指向实现类的指针
};
```

`UtilsImpl.h` (内部头文件,通常不公开):
```c++
include "BigContainer.h" // 只有实现类需要它!

class UtilsImpl {
public:
UtilsImpl();
~UtilsImpl();

void process(const BigContainer& data); // 这里的参数是具体类型
};
```

`Utils.cpp`:
```c++
include "Utils.h"
include "UtilsImpl.h" // 实现类在这里被包含
include // 假设我们用 vector 传递数据

// Utils 的构造和析构
Utils::Utils() : pimpl_(std::make_unique()) {}
Utils::~Utils() = default; // unique_ptr 自动处理

// Utils 的 process 方法
void Utils::process(const void data_ptr, size_t data_size) {
// 需要将 void 转换回实际的容器类型
// 这个转换过程需要知道原始数据是怎么组织的
// 这里假设 data_ptr 是一个指向 std::vector 的 void
const std::vector data = static_cast>(data_ptr);
if (data) {
// 调用实现类的 process 方法,这里才用到 BigContainer
// 需要将 vector 转换为 BigContainer,这部分逻辑在 UtilsImpl 中
// 或者直接在这里转换
BigContainer bc(data); // 假设 BigContainer 可以从 vector 初始化
pimpl_>process(bc);
}
}
```
Pimpl Idiom 的好处:
`Utils.h` 不再包含 `BigContainer.h`,依赖大大减少。
修改 `BigContainer.h` 或 `UtilsImpl` 的内部实现,通常不需要重新编译依赖 `Utils.h` 的其他文件,只需要重新编译 `Utils.cpp`。
将接口和实现分离,提高了模块化程度。

3. 使用“Handle/Body”或“Interface/Implementation”模式

这是一种更通用的设计模式,Pimpl Idiom 是其一种具体实现。

Handle/Body: 将一个对象的功能封装在一个“Handle”类(也就是我们看到的public接口类,如 `Utils`)中,而真正的实现逻辑则放在一个独立的“Body”类(如 `UtilsImpl`)中。Handle 类持有指向 Body 类的指针。
Interface/Implementation: 类似于面向对象中的抽象基类(Interface)和具体实现类(Implementation)。

这种模式的本质都是通过间接(指针或引用)来访问实际的功能,从而将具体类型的依赖延迟到 `.cpp` 文件中。

4. 理解包含陷阱(Include Guards 和 `pragma once`)的重要性

虽然这不是降低依赖的“方法”,但它们是避免因依赖而导致编译错误和重复包含的基础。

Include Guards (`ifndef ... define ... endif`): 防止同一个头文件被多次包含。如果没有它们,一旦发生循环包含或者多次包含,编译器会报错。
`pragma once`: 一个非标准的但被广泛支持的指令,效果与 include guards 类似,通常编译速度更快。

为什么它们与降低依赖有关?

因为即使你遵循了降低依赖的原则,一个不小心写错的 `include` 或者循环依赖仍然会造成问题。有良好的包含守卫机制,可以确保即使存在一些隐性的依赖链条,也不会因为重复定义而导致编译失败,这使得我们更容易识别和管理真正的依赖。

5. 利用标准库的优势

标准库(STL)的许多组件已经帮你做好了优化。

迭代器 (Iterators): 标准库的迭代器提供了一种抽象,你可以操作一个序列而无需知道其底层容器的具体实现细节(比如 `std::vector` vs `std::list`)。如果你只需要遍历一个容器,使用迭代器比直接操作容器的索引或指针要灵活得多。
算法 (Algorithms): 标准库的算法(如 `std::sort`, `std::find`)可以直接作用于迭代器范围,这使得它们可以与任何支持相应迭代器协议的容器一起工作,而无需关心容器的具体类型。

总结起来,降低文件间编译依赖的关键策略是:

1. 最小化头文件 (Header Files) 的内容: 头文件只包含“必须”的信息,比如类的声明、接口定义、类型定义等。避免在头文件中包含实现细节或不必要的依赖。
2. 推迟具体类型的引入:
使用前向声明来声明指针或引用,而不是实际对象。
将成员变量的复杂类型替换为指向它的指针或智能指针。
将函数的具体实现推迟到 `.cpp` 文件,使得函数签名只暴露必要的抽象(如基类指针、通用类型等)。
3. 封装实现细节: 使用 Pimpl Idiom、Handle/Body 等设计模式,将类的内部实现隐藏起来,只暴露一个精简的公共接口。
4. 依赖的转移: 将对复杂或易变类型的依赖,从公共头文件(.h/.hpp)转移到私有实现文件(.cpp)中。这意味着,对这些类型的改动只会影响少数 `.cpp` 文件,而不是整个项目。

好处显而易见:

编译速度显著提升: 这是最直接的好处。一个小的改动可能只会触发少量文件的重编译,而不是整个项目。
代码模块化和内聚性增强: 头文件越精简,越能专注于描述一个接口,而不是其实现。
代码的可维护性和可重用性提高: 当一个类不再需要直接知道另一个复杂类的细节时,它就更容易被重用,也更容易被修改,而不至于牵一发而动全身。
编译错误信息更易于理解: 当你只看到了一个前向声明的错误时,你知道问题可能出在实现文件,而不是一个庞大的头文件中的某个细节。

理解并实践《Effective C++》第31条所倡导的这些原则,是成为一名优秀 C++ 开发者的必经之路。它需要你对 C++ 的编译过程有深入的理解,并愿意在设计上投入更多的思考。

网友意见

user avatar

这个其实书里面已经说的很清楚了。

虽然各个编译器有自己的 trick, C++ 的文件依赖实现基本来说非常的简单。 如果你写一个

       #include "person.h"     

预编译器真的就是把这个文件拼接在了 #include 那里..

我们假设 person.h 里面包含

       class Person { public:  std::string name() const; ... private:  std::string mName; }     

而 main.cpp 依赖于 person.h。 那么基本上,不管你是怎么改 person.h, 甚至就算是 touch 了一下, 大部分依赖管理器也会让编译器重新编译一次,区别可能仅仅是好一点的编译器很快发现其实 person.h 根本没有变化, 编译时间稍微短一些而已。

那么问题来了, 我们现在看到 main.cpp -> person.h, 意味着任何时候 person.h 的改变都会导致 main.cpp 重新被编译。在现实情况中, 甚至会有上百的个文件都会依赖于 person.h, 我们并不想因为 person.h 被修改就导致所有依赖于它的文件被编译,那么怎么做呢。 书里面说了一个小 trick, 把类和类的具体实现分开 - pimpl idom (PIMPL, Rule of Zero and Scott Meyers).

基本上是把这个 Person 类拆开

       class Person { public:   std::string name() const;     ... private:   class PersonImpl * pImpl; }     

in Person.cpp

       #include "personimpl.h" std::string Person::name() {     return pImpl->name(); }      

PersonImpl 作为一个具体的实现类

       class PersonImpl { public:  std::string name() const {     return mName;  }  ... private:  std::string mName; }      

这里你可以看到, person.h 里面只是包含了接口信息,具体的实现挪到了PersonImpl 这个类。 由于 Person 对 PersonImpl 的引用是一个指针, 而指针大小在同一平台是固定的。 所以 person.h 根本就不需要包含 personimp.h ( 注意 person.cpp 需要包含 personimpl.h, 因为需要具体使用到 PersonImpl 的函数)。

于是文件关系依赖改变为:

       main.cpp -> person.h person.cpp -> person.h, personimpl.h personimpl.cpp -> personimpl.h     

所以你看到, main.cpp 和 personimpl.h 彻底解除了依赖关系。如果还有一百个文件依赖于 person.h, 而你又想改 Person 的实现。 由于 Person 的实现在 personimpl.cpp/h 里面, 不管你怎么去搞, 由于你压根就不会去碰 person.h , 所有的依赖 person.h 文件都不会被重新编。

说了怎么多好处, 那么这里给题主提几个问题思考下:

- 你真的想把你的实现藏在另外一个文件嘛, 你确定看代码的人看到这种一层套一层的实现到处找你的代码的时候不会想砍死你。。

- 这个依赖关系又会把编译时间降低多少呢?如果仅仅是几个文件依赖这个类定义,多搞一个类出来是否值得?

- 说到把接口和实现分开, 你肯定在想, 尼玛, 我直接把 Person 搞成一个接口不就行了嘛。 为啥还要这么麻烦。

=========

如果你思考过这些问题,说明你已经摆脱了教科书里面这些条条款款, 进入真正的工程实践领域了。

这种降低文件依赖关系的做法一般并不会在开始写代码的时候做, 这个叫 Premature optimization, 因为你可能在优化一个根本就不存在的问题。

在大工程里面, 把代码写得清晰,容易懂, 比什么优化都重要, 而如果不是大工程, 编译时间本来也就不长。

而你代码写好了以后,如果发现很多文件依赖一个类实现,再把这个类改成 pimpl idom 也不迟。

甚至关于优化编译时间, 也有非常多的技巧, 比如, 另外一个极端是,就算你不想拆代码, 构造一个新的 cpp, 把其他所有的 cpp 全部包含进去。

all.cpp

       #include "person.cpp" #include "personimpl.cpp" #include "main.cpp"     

依赖关系不是复杂难搞嘛, 我全部包含在一起,虽然不管改个啥都要重新编,但是只用编一个 cpp 啊,不管编译还是链接都要快几个数量级。 ( opera 用了这个 trick 编译时间从 半个小时降到了 5分钟。。)

对了,对于最后一个问题,接口 + 实现的最大问题是接口本身不能被直接构建出来, 所以

- 没法在栈上面用

- 需要一个辅助的工厂类

类似的话题

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有