• Home
  • About
    • Road to Coding photo

      Road to Coding

      只要那一抹笑容尚存, 我便心无旁骛

    • Learn More
    • Email
    • Github
  • Posts
    • All Posts
    • All Tags

<星月夜> Implementations

09 Oct 2018

讨论过设计与声明的细节之后, 实现自然水到渠成, 但是在实现的时候也不是那么顺利的, 其中也有许多坑 那么, 常见的坑都有哪些, 又有哪些方便易用的facilities呢, 如何提高效率是我们一直要关心的问题

Item 26: Postpone variable definitions as long as possible

核心正如标题: 尽量延长定义式出现的时间

你可能会疑惑,** C中都是在顶层声明定义所有变量*, 为何C++中变成如此 ? *C++程序员都有C基础, 这句话可能在1990s还比较适用, 现在是有点扯淡, 不过我有幸还是这样一批人 也不知道该说是我邮的教育方针好, 还是教育方针好呢 ?

先来看个例子吧, 为什么要尽量拖晚定义式出现的时间, 不显得不好看吗?

void func_deal_something()
{
    using namespace std;
    string test_string("...");

    if (...)
        throw runtime_error("...");    // 症结所在
    deal_with(test_string);
    ...;
}

众所周知, C++是支持异常系统的, 再没有任何一个catch的情况下, 最后会被std::terminate()终止 假设上述例子, 我们在异常之前定义变量, 然后抛出异常, GG了, 有一个问题: 没错, 变量还没有被使用, 就析构了, 这便是有时候浪费资源的一种情况.

那么, 我们可以这样修改:

void func_deal_something()
{
    using namespace std;
    if (...)
        throw runtime_error("...");
    string test_string;
    test_string = "...";
    deal_with(test_string);
    ...;
}

这样可以吗 ? 虽然很抱歉, 但还是不合适的 在了解了C++之于构造和赋值的问题之后, 我们是建议: 使用 初始化 <= 构造 + 赋值的 为什么呢? 根本原因: 提高效率

即,应该如此编码:

void func_deal_somthing()
{
    using namespace std;
    if (...)
        throw runtime_error("...");
    string test_string("...");
    deal_with(test_string);
    ...;
}

虽然只是一点小变动, 但是它说明了一个核心问题: 定义式应该 推迟到能初始化它的时刻 才合适

下面来聊聊C/C++中的定义(还有声明)出现位置的问题:

为什么推迟定义呢? 还不是因为怕中间出意外, 造成无用开销. 所谓无用开销, 指的就是: 类似于上面的string一样的东西, 懂了吧?

那么, C中为什么还是顶层定义呢? 1. C++中的异常机制, 更容易导致无用开销, 同时C与C++的内存布局也限制了这一部分内容 2. 还在使用顶层定义, 起码你用的是C99之前的标准, 最新的C标准支持使用时定义 这里慨叹一下我们学校仍在给学弟学妹使用VC++6.0 甚至于Turbo C此类上古神器

现在有一个问题: 既然推迟定义位置, 那么loop的两种写法如何决断呢?

// 1.
int i;
for (; i != N; ++i) { ... }
// 2.
for (int i = 0; i < N; ++i) { ... }

如何选择呢? 这里我们要考虑的是开销问题,

  1. 1个构造 + 1个析构 + n重赋值
  2. n个构造 + n个析构

当赋值的开销 > 析构 + 构造(1组) 时, 选用方式二更合适一点 当然, 在有多次构造, 析构的时候, 开销也是不容小觑的, 方法一就显得更合适了

但是, 方法一还存在着污染命名空间的问题, 总体上而言, 方法二用的更多

Item 27: Minimize casting

转型(casting) 其实是一个相当花式的玩意, 用这个可以写出很多秀的操作 但是, 作为开发来说, 是有点恶心. 而且, 转型并非无损, 还是有一定程度上开销的.

其他语言不太了解, 但是C/C++中的转型, 我们概括一下, 有下面几种:

  • 旧式转型
    • (Type) var:一般形式上的转型
    • Type (var): 函数形式的转型(非FP), 联想构造
  • 新式转型
    • static_cast<T>(var): 静态转型, 一般转型比较通用, 不过必须是继承体系链上的
    • const_cast<T>(var): const转型, 一般用于取消常量性的转换, 仅此转型可达到
    • reinterpret<T>(var): 位层面上的转型, 可以理解为C中进行的指针强制类型转换等
    • dynamic_cast<T>(var): 动态转型, 一般用于多态需求, 可以自上向下转换, 当然开销很大

大概说说几种转型的作用范围及作用:

  1. 一般进行的类型转换就使用static_cast就好了
  2. 用于函数重载取消常量性, 即使用const版本实现non-const版本时使用const_cast, 一般也仅此使用
  3. 进行花式指针操作, 在确保正确的时候, 进行指针层面的位解释时使用reinterpert_cast
  4. 拥有基类handle, 想要调用派生类的func时, 使用dynamic_cast, 不过我们一般用其他手法规避

使用程度: static_cast, reinterpert_cast >> const_cast > dynamic_cast

下面来处理两个问题:

对于新式转型的理解

新式转型为何出现呢? 仍然是C++最核心的目的: 避免错误, 明确语义的目的

原来的(Type)var 或者 Type(var)涵盖了所有的转型动作, 语义十分混杂 C++将与拿来的转型拆成了, 四部分. 明确了语言, 使得coding也更加合理

比如说, 我们可以看看这个: static_cast<T>(var)与Type(var)

void do(int);

void func1()
{
    double a;
    do(int(a));      // 即使我们不这样做, 隐式的类型转换也会这样干
}

void func2()
{
    double a;
    do(static_cast<int>(a));     // 新式转型应该这么写
}

真的要说的话,其实使用就是类型转换更加符合我们的常识认知: 构造一个符合类型的对象

对于explicit constructor, 是我们如今唯一值得使用旧式转型的地方

在其他之处, 我们建议, 使用新式转型来达到更加安全的转型, (毕竟C的旧式转型是不安全的)

那么, 还有要讨论的吗? 有 ! 我们有时候会被新式转型迷惑

情境: 现在持有一个派生类D的handle, 我们要在其上调用基类部分的操作, 怎么办? 最现实的情况, 派生类上使用基类版本的虚函数, (别问为什么, 就是这么恶心的需求)

class Base {
public:
    ...
    virtual void func();
};

class Derived : public Base {
public:
    ...
    virtual void func();
};

// User code
void Derived::func()
{
    static<Base>(*this).func();   // Error!
    ...
}

上面的代码乍看一下, 可能会觉得并没有错误, 在看一眼: static<Base>(*this).func(); == Bse(*this).func();

那么, 问题来了: 实际上并非在本对象上使用基类版本的func() 而是本对象基类副本上使用, (成员函数只有一个, 无所谓) 可以说, 这与我们的预期大相径庭, 基类部分是操作失败的, 用侯捷先生的话来说, 就是 败坏数据结构之始

该如何正确使用呢?

void Derived::func()
{
    Base::func();     // Right !
    ...
}

直接访问公共接口即可, *this是被默认传入的.

关于新式转型与旧式转型, 还有问题吗 ? 有 ! 就是关于C++内存模型的问题

在C++中, 对于同一个对象, 如果它的继承体系比较复杂的时候, 他会有多个指针, 指向同一对象 inhertiance: A, B -> C 则, 对于C c;中c的handles, 如果你使用A *, B *, C *极有可能是不同的值 (单继承少, 多继承经常出现此问题) 在Java/C#中, 因为他们有header, 所以是一致的

这说明什么呢? => 我们不能assume C++ memory model, 因此乱用转型, 自找苦吃 即使是某一平台上的内存模型你清楚, 也不能如此, 这样会失去兼容性

关于新式转型与旧式转型, 还有问题吗 ? 这次真的没有了

Dynamic_cast的杂七杂八

至于const_cast/reinterpert_cast的使用, 其实也就那么一回事, 了解一下, 并没有那么多事

但是dynamic_cast是个恶心的东西. 它是C中没有的, 因为C不支持继承机制. 那么, 他有什么问题呢?

就是: 它的机制是比较字符串判断是否是继承体系中的类型 一旦继承体系足够复杂, 你也懂得吧 (一般4层是个界限)

在足够复杂的继承体系中使用运行时类型断定 (一般结合RTTI使用), 无疑是自寻死路

我们在什么时候使用它呢 ? 与之前的例子相反: 手持基类handle, 想要调用派生类版本内容时, 使用dynamic_cast 你会说, 那不是有虚函数么, \抠鼻, 问题来了, 这个对象是你估计可能是D类, 但是只有一个B的指针, 你又想正常使用它的函数(non-virtual) 这个时候, 就要运行时类型判定, 懂了吧

看个例子:

class Base {
public:
    virtual ~Base(){};
};

class Derived : public Base {
public:
    void func() { std::cout << "Hello World" << std::endl; }
};

void func_do(Base *ptr)
{
    dynamic_cast<Derived *>(ptr)->func();
}

int main(void)
{
    Derived d;
    Base *ptr = &d;

    func_do(ptr);
    return 0;
}

直接是不能使用func()的, 道理你也懂吧 (undefine reference to.....)

之后, 就是如何规避 使用dynamic_cast

两个手法: 1. 使用类型安全的容器(Containor)存储相关的派生类handle, 直接根除问题 2. 使用virtual-func机制, (当然你要承担virtual的开销)

因为这两个手法是比较容易理解的, 我们在这里就不举例子了.

Item 28: Avoid returning “handles” to object internals

emmm, 这一个主题, 其实容易被人误解, 这里的”handles” 指的是 句柄, 即指针, 引用, 智能指针等

它不同于我们封装数据后, 使用的get()系列函数

为什么呢?

因为句柄可以直接控制数据,可能会引发败坏数据结构的问题 侯捷先生翻译, 某些方面还是可取的 那么, 这一主题, 我们强调不要返回句柄, 原因在哪里呢?

破坏数据结构的封装性

虽然经常说的, OOP核心特性: 封装, 继承, 多态. 个人认为, 重要性排序: 封装 > 继承 >> 多态 然而, handles的返回, 却严重破坏了封装的特性

来思考下面的例子:

class Temp {
public:
    string getString() {            // 合格的get()函数
        return str;
	}
    std::string &getHandles() {     // 危险的返回句柄
        return str;
	}
private:
    std::string str;
};

正如上述例子: getString()是合格的get()函数, 保护了数据的封装性 getHandles()则是危险的函数, 因为他将类中的数据开放出接口, 是极大的隐患 (结合bitwise const以及mutable关键字)

因为可能会产出下面的代码:

Temp p;
p.getHandles() = "...";    // Error

在只是想要使用封装数据的时候, 意外修改了数据, 破坏了封装性

那么, 这样呢?

class Temp {
public:
    ...
    const std::string &getHandles() {
        return str;
	}
private:
   ...
};

是否会更好一点?

也仅仅是好一点, 因为有的时候, 还需要mutable修饰可修改的部分 那么, 合理的办法呢? 既然只是使用数据, pass-by-value有何尝不可 不能因为一部分的效率, 就置数据于不复深渊

悬垂指针的问题

其实, 返回handles更严重的一个问题在于: 导致悬垂指针的产生

悬垂指针: 指针指向的已经是无效的内存, 可怖的是: 这个指针仍可以使用 (这也是我们引入智能指针的原因)

class Temp {
public:
    Temp(A *ptr);
    string &get() {
        return str;
	}
private:
    string str;
};

// User code
auto ptr = new A;
const auto *pstr = &(Temp(ptr).get());  //Error

即使此处const, 仍然是错误!

为什么, 这是一个临时对象的handles, 下一行之后. 临时对象被析构, pstr就已经是一个dangling pointer了

之后使用pstr就会造成无法挽回的错误 !

忠告: 为了保护我们数据的完整性, 封装性不被破坏, 避免返回封装数据的handles

Item 29: Strive for exception-safe code

异常安全, emmm怎么说呢, OOP机制下, 这个是一定不可或缺的.为何要维护异常安全 ? 如何维护异常安全 ? 这些都是我们要讨论的问题

关于维护异常安全, 原因在于: 异常机制是会打断程序的正常控制流, 从而有可能造成资源, 数据的破坏 因此我们在异常机制中, 要关注: 如何做到异常安全

异常安全有两个要点:

  1. 不泄漏任何资源
  2. 不破坏任何数据

这便是异常安全的基准

泄漏资源

说到这个, 是不是觉得有一丝丝熟悉, 使得没错, 就是我们Chapter III的 Resource Management

资源, 一般指: 内存, 套接字, 线程, 互斥器, 条件变量等等resource 那么, 资源泄漏(leak resource)一般指的就是: 未释放资源或者重复释放资源

这里就不多扯淡了, 就是用RAII来进行资源管理 在如今(2018), 经历社区积淀, 官方FAQ之后, 使用RAII管理资源已经被证明是C++再没有GC情况下, 管理资源最方便, 安全的手段 C++11 STL中增加了facility – std::lock_guard<T>

破坏数据

什么叫破坏数据呢? 恶意/无意修改或失效 这些都会对我们的开发造成不利影响或者隐患

因此,我们要做到异常安全.

保证数据不被破坏, 有三个层次的异常安全:

  1. 基本承诺: 保证操作结束后, 数据一定有效
  2. 强烈保证: 失败为原数据, 只有失败成功两种结果
  3. 不抛出

我们用代码来看例子:

void Data::change_the_data(data t)
{
    this->data = nullptr;
    auto ptr = std::shared_ptr<data>(new data(t));
    this->data = ptr;
}

其中可以看到, 先对数据进行置空, 之后重新设置新的数据. 那么, 问题来了: **如果定义智能指针抛出异常, 现在的数据是什么样子 ? **

毫无疑问: 数据已经被设置为nullptr, 这个时候data被其他用户使用的结果, 可想而知.

这便是典型非异常安全.

那么, 我们首先来做到基本承诺: 保证数据有效.

调换一下顺序

void Data::change_the_data(data t)
{
    auto ptr = std::shared_ptr<data>(new data(t));
	this->data = ptr;
}

这样我们可以保证, 数据一定是有效的. 但是, 这个仅仅只是最低层次的保证. 通过语句顺序的调整基本可以做到

我们再来看看高层次一点的需求强烈保证: 只有成功/失败. 失败不对数据改变

也可以通过调换语句顺序实现, 但是我们提供更加合适的手法: Copy-And-Swap

说点私货, 另一个CAS指的是Compare-and-set 或 Compare-and-swap

这种手法叫做”拷贝-交换”手法: 将改变实现在对象的副本上, 成功后进行交换

void Data::change_the_data(data *t)
{
    using std::swap;
    std::shatrd_ptr<data> pNew(new data(*t));       //  Copy
    auto ptr = std::shared_ptr<data>(new data(t));
    pNew = ptr;
	std::swap(data, pNew);                          // Swap
}

这便可以帮助我们做到: 强烈保证. 在异常安全的道路上更进一步.

一般情况下: 无抛出 no-execpt其实十分困难. 最简单的: *有new的地方, 你能保证没有异常 ? *

一般无抛出: 仅仅用在内置类型, STL中, 用户自定义类型的时候, 基本上做到强烈保证就已经相当到位了

这里婊一下侯捷先生的翻译: (execption-safe code)异常安全代码, 翻译为”异常安全码”,真的很有误解性

异常安全级别

我们在开发一个大型系统的时候, 不可能只有一处异常, 而且异常套异常的情况多得是. 如何评价一个系统的异常级别呢? 木桶效应. 如果哪一处, 没有异常安全, 那么这个系统就是非异常安全的.

这就和女性怀孕一样: 你只能说怀孕, 或者未怀孕, 不能部分怀孕 (Scott Meyers先生的比喻)

因此, 做到基本承诺其实已经十分难得了, 但是, 我们建议异常安全级别越高越好, 也就是说强烈保证是我们追求的目标

做到系统的异常安全十分不易, 但我们应该为之不懈努力.

Item 30: Understand the ins and outs of inlining

Emmm, 之前我们在Item 02中也提到过了 用inline, const, enum较少预处理器的使用 而且, 在c99(C语言标准1999)中也提到过了inline关键字

那么, 真正清楚inline吗?

我们从最简单的起步, 什么是inline?

inline的内容

inline,即内联函数. 它是指将函数调用时, 使用函数本体去替换函数调用. 看着与#define类似, 实际上有很大不同.

因为inline function实际上仍然为函数, 他有着函数的行为, 不过减少了函数调用开销 但是#define (marco function)实际上是文本替换, 并非真正的函数行为. 不然我们写个#define max(a, b) ((a > b) ? (a) : (b))里面为什么这么多括号…

一个简单的例子:

inline void func()
{
    cout << "Is this an inline function" << endl;
}

(其实这个例子是有问题的, 我们后面再说)

如何inline

再来说说如何inline, 有两种手法: 显示inline和隐式inline

隐式inline, 就是在类中定义的成员函数(注意是定义, 不是声明)

class Temp {
public:
    Temp() {
        ...
	}              // 隐式的inline函数  (当然Constructor, Destrucotr inline十分危险)
};

显示inline, 就是在类体外,使用inline关键字来定义函数

class Temp {
public:
    void func();
};

inline void Temp::func()        // inline key-word 用在函数定义上
{
    ...;
}

inline 和 template的纠葛

inline和template能有什么纠葛呢? 他们一般情况下都要写在headers中, 所以会给人产生误解: template一定inline

这个一般不会提高效率, 甚至会带来严重危害!

我们inline为什么写在headers中: 一般情况下, C++的inline都发生在编译期间, 有极少的运行环境, 可以在运行期间完成inline

建议把inline函数的定义放到头文件中,在每个调用该inline函数的文件中包含该头文件。 这种方法保证了每个inline函数只有一个定义,且程序员无需复制代码, 并且不可能在程序的生命周期中引起无意的不匹配的事情。

摘自 C++ Primer 第三版

也就是说inline定义式可以放在头文件中, 因为要保持一致. non-inline则反而会导致”重定义”, 所以只能放声明.

在来说说template, template毕竟只是模板, 一定要进行实例化, 实例化就必须编译器可见, 所以也放在头文件中 (当然也有极少的运行环境, 允许运行其进行实例化, 很少)

所以, template 与 inline没有必然关系, 不是模板就必须inline (指function template) 甚至会酿成大祸(后面inline的缺点)

再来点私货: static variable也是在头文件中

静态变量是脱离对象, 与类直接关联的变量, 一般放在头文件中 但是, 因为.h可能会被多个文件引用, 所以头文件中的静态变量, 并不会实例化 因此就需要在每个文件中, 声明静态变量, 否则不可见名字的

是否inline

其实这里, “是否inline”的小标题是有一定程度上的误解性的. 这里的是否, 指的是, 有没有成功inline

  1. 编译器建议, 并非强制 inline之后不是一定就能成功的, 要看编译器优化. inline只是表示有可能被编译器接受

  2. 实现关键字 inline是实现上用的, 在函数声明(现在应该叫函数原型 function prototype)上inline, 不接受inline

  3. 对于取地址的inline建议函数, 不会inline 看看下面这个例子:

    inline void func() {...}
    void (*p)();
    p = func;
    p();      // out-line
    func();   // inline
    

    p是函数指针, 需要确定的地址, 这次调用时不会被inline的 func()调用, 则会被inline

上面这一点也说明: inline不是指完全被inline, 而是对每一处调用而言.

inline优劣之分析

终于分析到inline的优劣!

inline实际上是将对函数的调用替换为函数本体. 它是真正的函数, 不过减少了函数调用的开销. 可以说是#define宏函数的语义明确版本, #define内容过于复杂, C++中将其含义明确

那么, 问题来了: 对于本体复杂的函数, inline会有什么下场 ?

代码膨胀 -> 目标码膨胀(obj) -> 换页机率增加 -> Cache命中机率降低 -> 换页失败更多 如果严重了, 甚至会导致系统抖动(System spooling), 不过这个就有点夸张了

也就是换句话说: 如果本体够小, 直接本体替换的代价小于函数调用的开销, 那么反而会降低code大小 即inline最大的优势: 降低函数调用开销, 对于短小, 频繁调用的函数, 提高系统效率

既然使用了inline, 你便要接受它的种种, 尤其是隐患:最严重的有两点. ( 1 + 2 )

  1. Debug十分不方便: 你无法追踪一个没有地址的函数 (全都inline了)
  2. 编译依赖性十分严重
  3. 代码膨胀(目标码亦是)

我们重点说说2, 因为inline是将函数展开, 所以只要有变动, 客户代码一定要重新编译 如果使用静态链接, 只要重新链接即可 (link 即可) 如果使用动态链接, 甚至你都没有察觉改变(去看链接文件的变化, 例外)

虽然有优有劣, 只要我们合理使用inline, 则能发挥其作用, 规避其危险

Item 31: Minimize compilation dependencies between files

这一条款我们讨论的是: 关于文件之间编译依存性的问题

这个问题会有什么影响呢? 当我们修改Class内容之后, 点击一下编译/建立(Compile/Build) 本来以为1s的时间, 结果等待了足足10s, 为什么会有这种情况, 我明明只改了一个变量名啊!

何况这还是我们平时的小项目, 体量足够大的项目, 会有什么后果呢, 想像就知道了

这个问题真的这么严重吗? 是的, 文件之间的编译依赖性过与复杂, 是软件设计的缺陷.(文件编译是恶心, 不及模块编译)

我们来用例子看看应该如何解决这样的问题:

class Person {
public:
    Person();
    ~Person();
    std::string get_name() const;
    int get_age() const;
    Date get_birthday() const;
private:
    int age;
    std::string name;
    Date birthday;
};

上面其实是我们经常说到的一个东西: Class (废话,我不说你也知道) 这个类其实是将接口(interface)与实现(immplementation)糅合在一起的.

那么, 这个东西具体会导致什么问题呢? 如果, Person Class发生改变, 你的用户代码, 就得直接重新编译 !

看上去是一个小问题, 那么要是多个#include呢? 那个体量基本上就是超出你预期估计的了.

因为我一直接触的就是C/C++, 文件编译在我看来其实是很常见的, 但是模块编译才是主流 以文件划分编译层次, 被证明的确是不好的组织形式, 不过也是历史原因 仔细想想, 一旦文件划分的不好, 强耦合的代码, 互相依赖性大 相比于层次区分明显地Java/C#/…模块编译的语言,

既然有这样的问题, 那么我们能否手工解决呢? (又是句废话) 肯定能咯

尝试减少编译依存性

上面说到: 根本原因是繁杂的#include机制, 那么, 我让他不#include不就行了? ( ͡° ͜ʖ ͡°)

那么, 看看这样写如何?

// person.h

namespace std {
    std::string
}
int age;
Date birthday;

class Person {
public:
    Person();
    ~Person();
    std::string get_name() const;
    int get_age() const;
    Date get_birthday() const;
};

两个问题:

  1. std::string的写法是错误的, 实际上是typedef basic_string<char> string特化版本 而且标准库设施不应该成为桎梏 毕竟标准库都是大佬写的对吧
  2. 来你说说看, Person有多大 ( ՞ټ՞) 你也懂得吧, C++实例化, 你要是不知道类多大, 怎么玩? 关于这个内容, 我们已知的是: 实例化类之前, 你得知道类的大小, 就得可见类的定义式. 但是, 我们目前的各种举措, 就是要避免”实现部分的内容”也嵌套在”接口部分中”.

这个时候, 我们可以去其他语言中寻求帮助: 比如Java/C#

据说他们都是有一种文件, 直接就叫interface, 其他都是class, 不得不说模块编译的确是很好的设计

import java.util.Scanner;
public class Main {
    public static void main(String Args) {
        Scanner sc = new Scanner(System.in);
        ...
	}
}

上面是一段简单地Java代码, 在创建类的实例化对象的时候, 产出的, 都是指针 也就是说, 我们不需要知道类的实现细节, 我们只需要一个指向它的指针即可

这不就是我们现在亟待解决的问题: 如果我们也用一个指针隐藏实现细节,那么这个接口文件岂不就是与实现独立开来, 用户代码就可以不用进行编译, 这样实现归实现的改变, 不会影响到接口文件, 当然接口文件改变, 不重新编译就怪了…

那么, 怎么做呢?

Opaque Pointer, D pointer, pimpl idiom

Wiki上叫做Opaque Pointer, Effective叫做pimpl idiom, 编译防火墙, Bridge Pattern 常见语言: Ada, C/C++, D, Modula-2 指: 将实现细节隐藏在指针之后, 使得用户不可见, 同时将实现从接口分离出去 作用:

在实现修改后, 使用的模块(module)不需要重新编译, 更多的细节可以隐藏在其他文件, 更重要的是可以提供不同版本, 二进制目标码的兼容性

看着是不是还挺不错, (让我说, 就是因为语言的历史原因, 不支持模块编译, 以及interface, 总是在模拟)

Pimpl Idiom

那么, 这种手法的要点在哪里呢?

有下面几个要点: (Effective总结的是真的到位, 不过我会改变一些说法)

  1. 能用handle, 就不用entity
  2. 用Declaration取代Definition
  3. 提供两份文件

pimpl手法的核心是: 尽量自满足, 不依赖其他#include, 否则依赖其他文件的声明式而非定义式

  1. 这点是核心, 我们使用只需要曝光名字(通过声明式)创建的handle,取代需要定义式才能创建的实体
  2. 使用声明式, 最好自满足就不依赖其他文件, 即使依赖其他文件,也是其他文件的声明式
  3. 规范化,提供两份头文件, “xxx.h” + “xxxfwd.h”

这里分享, 我个人理解时的一个疑惑:

Q:只提供声明, 没有定义, 那他怎么用啊? 而且只提供声明, … 反正就是各种不舒服

A:在 interface 中, 我们不提供定义.只有声明, 这种叫做 forward declaration 只要不使用 class 的细节(即实现部分), 可以不知道定义式. 因为我们的 interface 只负责说明它开放什么接口, 所以细节部分不知道最好.

Handle Class

使用Pimpl手法的类, 我们习惯意义上叫做”Handle Class”

// Personfwd.h
#ifndef _PERSONFWD_H
#define _PERSONFWD_H

#include <memory>
#include <string>
class PersonImpl;

#endif

// Person.h
#ifndef _PERSON_H
#define _PERSON_H

#include "personfwd.h"
class Person {
public:
    Person(std::string name, int age);
    Person(const Person &p);
    ~Person(){};
    std::string getName() const ;
    int getAge() const ;
private:
    std::shared_ptr<PersonImpl> pImpl;
};

#endif

// Person.cc
#include <iostream>
#include <memory>
#include "person.h"
#include "personfwd.h"


class PersonImpl {
public:
    PersonImpl(std::string name, int age);
    ~PersonImpl(){};
    std::string getName() const;
    int getAge() const;
private:
    std::string name;
    int age;
};

PersonImpl::PersonImpl(std::string name, int age) 
    : name(name), age(age) {}

std::string PersonImpl::getName() const
{
    return this->name;
}

int PersonImpl::getAge() const
{
    return this->age;
}

Person::Person(std::string name, int age)
    : pImpl(std::make_shared<PersonImpl>(name, age)) {}

Person::Person(const Person &p) : pImpl(p.pImpl) {}

std::string Person::getName() const
{
    return pImpl->getName();
}

int Person::getAge() const
{
    return pImpl->getAge();
}

// main.cc
#include <iostream>
#include "person.h"

int main(void)
{
    auto p = new Person("Crow", 20);
	
    std::cout << "Name: " << p->getName() << std::endl;
    std::cout << "Age: " << p->getAge() << std::endl;

    return 0;
}

以上便是一个pImpl的例子, 我们稍作分析: * *Class Person是我们使用的类, 它仅仅依赖于”person.h”, 我们将所有的Person实现细节都隐藏于 *class Person {… private: std::shared_ptr pImpl;}; 背后* *即使PersonImpl的实现改变, 也不会丝毫影响Person的Build, 不会导致UserCode的Recomplie*

理论上, 是仍然要提供一个 XXXfwd.h 来进行前置声明的

但是, 事实意义上: 没啥用, 首先是前置声明没多少, 因为我们合理的设计应该是模仿模块编译,类对应文件 平白增加文件数目, 而且是意义不大的文件, 并没有什么意义,反而造成目录文件冗余, 实在是东西太多

分离接口于实现, Pimpl是在使用上模拟, Java/C#的指针写法, 那么,我们有没有直接设计interface的方法

有, Abstract Class

Abstract Class

我们在Pimpl习语的使用, 是将实现隐藏起来.

下面的抽象基类手法, 则是真正意义上来描述接口 核心: 抽象基类, 指具有至少一个 pure-virtual的Base Class

pure-virtual的设计含义, 就是仅仅继承接口, 同时因为抽象基类是残缺类 (因为没有pure-virtual的实现) 所以,我们不能为其实例化.

怎么说呢, 抽象基类,是我觉得活生生模仿Java/C#而搞出来的东西, 但是它里面可以存数据, 后面再说

那么, Abstract Class的核心在哪里呢? 即是: 抽象基类设计接口, + Virtual函数实现功能

是的, 没错. 这同样是一种接口与实现的分离, 也是降低编译依存性的手段 只要接口不变, 根本无需Recomplie

既然我们使用了继承体系, 那么, 梗句不同的参数, 环境变量, 场景. 我们便可以设计不同的继承来建模

之后, 同样是使用基类指针, 来完成多态功能实现 (之前的TimeKeper).

那么, 产出派生类指针的函数, 即为工厂函数 factory function 婊一下侯捷先生的翻译: 这里是指虚拟的构造函数, 它直接翻译 virtual 构造函数, 误解性比较深 另一方面: 抽象基类基本不要构造, 同时virtual constructor确实太过分了 ( ՞ټ՞)

那么, 我们就来说说这个factory function怎么写

// 我们同样以Person Class为例子
class Person {
public:
    // Factory function
    static auto create(const std::string &name, int age) -> std::shared_ptr<Person>;
    virtual ~Person(){};
    virtual void printName() = 0;
    virtual void printAge() = 0;
    virtual void printJob() = 0;
};

// inheriting class
class Student : public Person {
public:
    // 保持形式统一, 加上Person的构造函数
    Student(std::string name, int age) : Person(), name(name), age(age) { ; }
    void printName();
    void printAge();
    void printJob();
private:
    std::string name;
    int age;
    std::string job;
};

// Factory function
auto Person::create(const std::string &name, int age) -> std::shared_ptr<Person> {
    return std::shared_ptr<Person>(new Student(name, age));
}

//User Code
int main(void)
{
    auto p = Person::create("Crow", 20);
    p->printName();
    p->printAge();
    p->printJob();

    return 0;
}


我们着重要说的就是, factory function. 关键点有:

  1. static, 抽象基类是残缺类,不能实例化, 我们调用只能使用静态函数
  2. arguments, 我们根据不同的参数, 配置, 环境,可以生成不同的基类指针(意为: 指向不同派生类的)
  3. shared_ptr, 使用智能指针, 更方便进行管理

好了, 至此我们降低编译依存性的两种手法, 介绍至此. 还有内容吗, 有

关于使用哪种手法的判断

  1. 考虑我们的项目是否大到考虑项目的耦合程度.如果没到一定程度, 其实具象类(相对于抽象类)也不错
  2. 评价Handle Class 以及 Abstract Class的选择 Handle Class: 中间存在指针的间接访问. 而且,建议只有一层pImpl即可, 多层,想想就算了 Abstract Class: 设计优秀, 但是vptr的开销不容小觑, 加上虚表的查询, 推断. 也, 你懂得…

上面虽然这么分析, 但是, 在良好的设计面前, 一定程度的开销, 也可以接受, 不是吗? 其实个人更喜欢这种明确的设计, 虽然有开销, 具象类, 有的时候是真的冗杂


说点题外话: 其实最近压力挺大的, 100天倒计时想想有点慌. 只希望这最后的100天能够把自己提升上去,最简单的, 提高效率, 逼自己努力! 不过,这中间, 期中考试, 期末考试, 纳新, 是有点操蛋, 靠

至此, Chapter V结束, 请期待下一篇, 另外添加RSS订阅之后, 还请点个订阅呗



EffectiveC++ Share Tweet +1