小类 大对象 C
时至今日, C++
的核心战场在于:对于性能,空间和实时性有高要求的系统。
而在这类系统上,也有其特定的约束和挑战:
- 在这类系统上, 内存管理 始终是个需要关注的问题。而通用内存管理算法,要么容易导致内存碎片,要么会导致内存浪费。而为了避免这样的问题,最好是自己定义内存管理器。
- 内存分配是可能失败的。为了避免这样的问题,高可靠系统的做法一般是按照系统定义的规格预先分配内存。比如,系统承诺的规格是
10000
个Session
,那么Session Instance
相关的内存,会按照规格,在系统启动时就预先分配10000
个Session Object
需要的内存。其背后的原则是: 如果失败,则尽早失败 (fail fast
);如果内存不足,就应该在未发布时,通过系统加载就可以及时发现;而不是把风险留到在业务现场运行时发生失败。 - 对于需要大量销售的设备,成本很重要。而内存作为成本构成,也是非常宝贵的资源。另外,由于竞争对手的存在,如何在让客户付出相同的成本下,能够支撑更多的业务,始终是一个重要的指标。
- 对于高性能计算系统,所有的 领域对象 ,尽管部分数据也可能会持久化,但在运行时,都会放在内存之中,与另外一些纯粹的运行时状态一起,构成了运行时领域对象的整体。而对于有高可靠性要求的系统,运行时依然会对需要持久化的状态进行持久化——那怕只是采用 主备方式 其实也是一种持久化——所以系统事实上是 无状态 的。
该如何在这类系统上应用 小类,大对象 的设计模式,将是本文随后讨论的内容。
委托
回到 小类,大对象 的文章里的例子,我们首先 角色 实现分割到了不同的类中,从而得到了四个类:
struct ConcreteChild : Child { void getAdviceFromParent() {...} private: };
struct ConcreteParent : Parent { void tellStory() {...} void playGameWithChild() {...} private: };
struct ConcreteBoss : Boss { public void assignTask() {...} public void motivate() {...} private: };
struct ConcreteUnderling : Underling { public void acceptTask() {...} public void reportStatus() {...} private: };
然后,我们根据不同对象的需要对这四个对象进行组合。而首先进入我们视野的,是我们最熟悉的 委托 ( Delegation
):
struct TypeAPerson { void tellStory() { parent.tellStory(); } void playGameWithChild() {
parent.playGameWithChild(); } void getAdviceFromParent() { child.getAdviceFromParent(); } void acceptTask() {
subordinate.acceptTask(); } void reportStatus() { subordinate.reportStatus(); }
private: ConcreteParent parent; ConcreteChild child; ConcreteSubordinate subordinate; };
毫无疑问,这是令人生厌的 中间人 ( MiddleMan
)实现(参见 重构 )。
另外, TypeAPerson
将自己作为一个整体展现给了所有客户;而不同客户真正需要的却是不同的 角色 ,因而,无论从 依赖范围 角度,还是 依赖稳定度 角度,都无疑增大了系统的耦合度(参见 变化驱动:正交设计 )。
因而,按照我们最初的意图,如下的实现方式才是我们真正想要的:
struct TypeAPerson { Parent& getParent() { return parent; }
Child& getChild() { return child; }
Subordinate& getSubordinate() { return subordinate; }
private: ConcreteParent parent;
ConcreteChild child; ConcreteSubordinate subordinate; };
TypeAPerson
仅仅应该 聚合 (组合)其所需的 角色实现 ,其唯一的职责是当做一个 角色工厂 ,面对不同的客户,将对象转化为不同的角色:
void f(Parent& parent) { parent.tellStory(); parent.playGameWithChild(); }
void g(Subordinate& subordinate) {
subordinate.acceptTask(); subordinate.reportStatus();
}
TypeAPerson person1; f(person1.getParent()); g(person1.getSubordinate());
从这段示例代码,我们可以清晰的看出, f
和 g
是完全不依赖 TypeAPerson
的,而只依赖自己真正需要依赖的 角色 。因而,如果 TypeBPerson
也实现了相关角色的话,它也可以和 f
, g
配合。如下:
struct TypeBPerson { Parent& getParent() { return parent; } Subordinate& getSubordinate() {
return subordinate; } private: ConcreteParent1 parent; ConcreteSubordinate subordinate;
};
TypeBPerson person2; f(person2.getParent()); g(person2.getSubordinate());
通过这个例子,我们可以清晰的看出:将 上帝类 根据自己的 上下文 需要,分拆成多个 角色 类的好处:
- 客户代码仅仅依赖的自己所需要依赖的 角色 ,而不关心提供 角色 服务的对象,这解开了 客户 与具体对象之间的耦合; 这不仅 缩小了依赖范围 ,也让客户 向着稳定的方向依赖 ;
客户代码只依赖某个角色
- 每个对象自身的不同角色实现是更加 高内聚 ,更加 单一职责 。角色与角色之间的耦合也从上帝类那种缺乏封装,从而更容易导致高耦合的方式中解脱出来。让各个角色代码都更加 正交 ;
- 不同对象间的角色,如果实现是相同的,可以直接复用,这让代码复用更加容易;
- 对象对多重职责的实现更加简单,只需要通过一个承担 角色工厂 职责的类来实例化对象。
Can We Do Better?
从 委托 的实现方式看,已经基本上达到了我们的意图。
但是,我们也注意到:我们不得不让 TypeAPerson
提供一组 get
接口,来暴露自己实现的 角色 。这些 get
接口,像 Singleton
的 getInstance()
一样。因而,上述的实现模式也是一种 创建者 模式。这也正是我们将其称之为 角色工厂 的原因。
那么,是否还有更为简便的方法来实现 角色工厂 的职责?
我们不难发现: TypeAPerson
与它所承担的 角色 之间存在 IS-A
关系;而且,由于 TypeAPerson
没有任何业务逻辑代码,从而也没有改写任何一个父类(角色)的行为,因此这种 IS-A
关系必然是满足 里氏替换原则 。那我们为何不试试多重继承:
struct TypeAPerson : ConcreteParent , ConcreteChild , ConcreteSubordinate { };
这样的方式,在组合 TypeAPerson
时,明显比 委托 的实现方式更为简洁。
而在调用侧使用角色时,由于 IS-A
这种关系的存在,其角色转换也可以自动完成,从而也更为简洁:
TypeAPerson person1; f(person1); g(person1);
当然, 多重继承 的实现方式,相对于 委托 方式,还是存在一点缺点:你无法阻止 ParentAPerson
可以向 ConcreteParent
这个更具体的角色转化;但在 委托 方式下,由于 ParentAPerson
的具体组合的对象都作为了 私有实现细节 ,然后通过 getter
这种更有弹性的函数方式,将具体的角色实现,比如 ConcreteParent
,转化为更抽象的角色 Parent
;从而具备更好的封装性。
对于这类问题,其解法有二:一则可以通过语言提供的机制进行强制约束,二则通过人为的约定。多重继承的方式,在 C++
里没有强制禁止向一个代表某种具体实现的父类进行转换的手段,但站在便利性的角度,我们更倾向于选择通过人为的约定。毕竟我们清晰的知道我们的设计意图是什么。
角色依赖
在之前的例子中,为了突出要点,给出的各个 角色 的实现都是孤立的。但在实际项目中, 角色 之间存在依赖关系,也是一种常见的现象。
比如,在我们的例子中, TypeBPerson
的 下属 角色的某个实现,需要调用 上司 角色所提供的服务。如下图所示:
角色依赖
这个难不倒我们,我们可以让 下属 角色持有一个指向 上司 角色的指针,然后在构造 下属 角色时进行 依赖注入 。如下是一种 委托方式 的实现:
struct ConcreteUnderling : Underling { ConcreteUnderling(Boss& boss) : boss(boss) {} void acceptTask() {
boss.assignTask(); } private: Boss& boss; }; struct TypeBPerson { TypeBPerson() : underling(boss) {}
Boss& getBoss() { return boss; } Underling& getUnderling() {
return underling; } private: ConcreteBoss boss; ConcreteUnderling underling;
};
这样的实现,存在如下问题:
首先,需要确保不同角色的构造顺序,一旦角色 Underling
依赖了角色 Boss
,那么,在 TypeBPerson
里,就最好确保 boss
定义在 Underling
之前,以免由于构造顺序所造成的调用问题。
其次,一旦一个角色引用了另外一个角色,那就需要通过引用进行 依赖注入 。这会增加由于引用所消耗的内存,对象间的关联越多,那么指针消耗的空间就越大。
尤其是当我们追求 高内聚,低耦合 的设计时,伴随而生的是很多很小单一职责的类,类与类之间会通过引用进行职责 委托 。这对于那些内存不是一个重要问题的系统而言,或许并不重要。在内存珍贵的嵌入式设备上,这会是一个问题。
而这个问题,也会反过来约束 C++
程序员即便知道 高内聚,低耦合 是正确的,在内存约束面前,也只能采取更糟糕的实现方式。
How About Inheritance?
对于 角色关联 所导致的问题,换成 多重继承 也不会让情况变得更好:
struct TypeBPerson : ConcreteBoss , ConcreteUnderling , { TypeBPerson() : ConcreteUnderling(*this) {} };
这两种实现,差别在于从 成员变量 便为 继承 ,而不变的是:都要注意声明顺序,都会造成由引用带来的内存消耗。
工厂方法
幸运的是,相对于 委托 ,通过 继承 ,我们可以拥有更多的武器。
对外部的依赖,在继承体系下,我们可以通过著名的 工厂方法 来引入,而不是通过经典的 构造时依赖注入 方式。比如:
struct ConcreteUnderling : Underling { void acceptTask() { getBoss().assignTask(); }
private: virtual Boss& getBoss() = 0; }; struct TypeBPerson : ConcreteBoss , ConcreteUnderling {
Boss& getBoss() override { return *this; }
};
通过这样的方法,首先解决了角色构造顺序的问题。因为,一个角色对于另外一个角色的引用,只有到整个对象构造结束后,运行时才会进行获取。当然你需要避免任何在构造函数里对于其它角色的引用,而事实上,根据多个项目的实践,这种 构造时引用关系 都可以合理的避免。
内存优势
但这个例子还不能彰显 工厂方法 的内存优势。让我们换个例子:
struct Role1 { Role1 ( Role2& role2 , Role3& role3 , Role4& role4) : role2(role2) , role3(role3) , role4(role4) {}
private: Role2& role2; Role3& role3; Role4& role4; };
struct Object : Role1 , Role2 , Role3 , Role4 { Object() : Role1(*this, *this, *this) {} };
这种通过直接引用的方式,让 Role1
需要消耗三个引用的空间开销。
现在将其换成基于 工厂方法 的实现:
struct Role1 { private: virtual Role2& getRole2() = 0;
virtual Role3& getRole3() = 0; virtual Role4& getRole4() = 0; };
struct Object : Role1 , Role2 , Role3 , Role4 { Role2& getRole2() override { return *this; }
Role3& getRole3() override { return *this; }
Role4& getRole4() override { return *this; } };
现在我们可以清晰的看出,对于 Role1
,无论其对外部有多少个角色引用,都只需要耗费 一个 指针内存的开销,那就是 虚表指针 ( vptr
)。如果 Role1
本来就存在其它 virtual
函数,那么这些外部引用,无论存在多少,都没有增加任何额外的空间开销。
简化工厂管理成本
我们知道,按照 高内聚,低耦合 的实现方式,会导致一堆小类。如果不用 小类,大对象 的方式,而是让一个个小类可独立实例化。那么这些小类之间如果存在引用关系,一则需要更多的内存消耗,二则,你还不得不需要写很多 工厂 来对这些小对象进行构造和关联。比如:
struct A { A(B* b, C* c) : b(b), c(c) {} private: B* b; C* c; };
struct AFactory { static A* create() { B* b = ... C* c = ... return new A(b, c); } }
当对象种类很多时,这样的承担 工厂 职责的代码就会很多。而这类的代码是极其无趣而令人厌烦的。
当然对于其它应用语言,往往会提供一个框架,来管理这类工厂职责。比如 Java
的 Spring
。程序员需要做的是:将对象,及对象间的关联关系,通过 xml
配置文件进行描述, Spring
框架会根据这个配置文件来履行工厂职责。
可是,这样的方法在嵌入式 C++
下不是一个可行的途径,程序员们还是不得不亲自去实现。
而在 小类,大对象 的实现模式下,固定模式的 工厂方法 就完成了这些,程序员不会比 Java
下用 XML
进行配置,需要付出的努力更多。
struct Object : Role1 , Role2 , Role3 , Role4 { Role2& getRole2() override {
return *this; } Role3& getRole3() override { return *this; } Role4& getRole4()
override { return *this; } };
Once For All
更妙的是,在一个对象上,无论一个角色被多少其它角色引用,最后都只需要实现一次。比如:
struct Role1 { private: virtual Role4& getRole4() = 0; }; struct Role2 {
private: virtual Role4& getRole4() = 0; }; struct Role3 {
private: virtual Role4& getRole4() = 0; };
struct Object : Role1 , Role2 , Role3 , Role4 {
private: Role4& getRole4() override { return *this; }
};
即便对于一个来自于外部的角色,也是如此:
struct Object : Role1 , Role2 , Role3 { Object(Role4& role4) : role4(role4) {}
private: Role4& getRole4() override { return role4; } Role4& role4; };
更清晰的依赖语义描述
使用 工厂方法 来表达依赖的例子如下:
struct Role1 { void f() { getRole2().doSth(); getRole3().blah();
}
private: virtual Role2& getRole2() = 0;
virtual Role3& getRole3() = 0; };
不难看出,这段代码的语义如下:
use 语义
并且这样的实现方式也是完全模式化的,因而我们定义如下两个宏:
#define USE_ROLE(RoleType) \\\\ virtual RoleType& get##RoleType() = 0
#define ROLE(RoleType) get##RoleType()
通过它们,之前的例子就可以修改为:
struct Role1 { void f() { ROLE(Role2).doSth(); ROLE(Role3).blah(); }
private: USE_ROLE(Role2); USE_ROLE(Role3); };
不难发现,一个角色,通过 USE_ROLE
语义,仅仅声明自己对另外一个 角色 的依赖,却完全无需关心这个角色的实现来自何处,也完全无需关注谁会注入给它。这实现了与经典 依赖注入 方式完全相同的语义,达到了完全相同的解耦效果。
而对于工厂的实现,同样也有明确的模式和清晰的语义:
struct Object : Role1 , Role2 , Role3 , Role4 { Role2& getRole2() override {
return *this; } Role3& getRole3() override { return *this; } Role4& getRole4()
override { return *this; } };
因而,我们可以定义如下的宏:
#define IMPL_ROLE(RoleType) \\\\ RoleType& get##RoleType() override { return *this; }
利用它,我们就可以将工厂代码改写为:
struct Object : Role1 , Role2 , Role3 , Role4 {
private: IMPL_ROLE(Role2);
IMPL_ROLE(Role3);
IMPL_ROLE(Role4);
};
直接引用,还是工厂方法
直接引用 ,相对于 工厂方法 ,会带来更多的内存成本,以及工厂管理成本。
但 直接引用 ,会存在微弱的性能优势。根据我们的项目经验,这些性能优势微乎其微。但如果在你的项目中,经过事后测量,确实发现热点处可以通过 直接引用 提升性能,那就可以在那个点,将 工厂方法 ,改为 直接引用 的方式。而这个改动,并不困难。
继承树倒置
当使用 单根继承 时,如果子类没有任何代码,这样的继承是没有太多意义的。比如:
struct Base { void foo(); void bar(); private: int a; int b; }; struct Derived : Base {};
但是,当使用 多重继承 时,子类没有任何实现代码,却表达了一个非常有价值的语义: 组合 。
TypeAPerson
有效的将多个类的数据和行为都组合到一个对象上。最重要的是,这个没有任何实现代码的子类,恰恰是我们设计时所追求的单一职责 —— TypeAPerson
的唯一职责是:将所有 角色 组合到一个 对象 身上。
这样的设计是一种以 组合 的方式,最终 聚合 到单个 对象类 。它和经典的 单根继承 方式所导致的继承树正好相反。因而,我们也称它为 继承树倒置 模式。下图是来自于一个项目的例子:
继承树倒置
继承优于委托
通常在设计中,我们得到的建议往往是: 委托优于继承 。其原因在于: 委托 是黑盒复用,而 继承 是一种白盒复用。
但正如我们之前讨论的,在 多角色对象 的实现中,最终的 对象类 ,没有任何业务实现代码,因此不会对父类产生任何实现上的依赖。 角色类 的所有实现,对 对象类 类而言,在逻辑上和一个黑盒无异。
而反过来, 继承式组合 ,相对于 委托式组合 ,至少有如下优势:
- 简化了 组合方式 ;
- 大大降低了 内存开销 ;
- 消除了 角色构造顺序 问题;
- 大大简化了 依赖管理问题 ;
- 对象到角色的自动转换;
因而,在 多角色对象 的场景下, 继承式组合 要优于 委托式组合 。
为何这样的多重继承不邪恶
过去, 多重继承 在面向对象社区内一直颇有争议。大多数书籍都会建议: 尽量避免使用多重继承 , 要谨慎的使用多重继承 。于是 多重继承 就逐渐变为程序员唯恐避之不及的东西。
多重继承 的邪恶之处主要体现在几个方面:
- 菱形继承 所带来的数据重复,以及名字二义性。因此,
C++
引入了virtual
继承来解决这类问题; - 即便不是 菱形继承 ,多个父类之间的名字也可能存在冲突,从而导致的 二义性 ;
- 如果子类需要 扩展 或 改写 多个父类的方法时,造成子类的职责不明,语义混乱;
- 相对于 委托 , 继承 是一种白盒复用,即子类可以访问父类的
protected
成员,这会导致更强的耦合。而 多重继承 ,由于耦合了多个父类,相对于 单根继承 ,这会产生更强的耦合关系。
但我们看看 TypeAPerson
,它没有任何代码。因而它没有操作任何父类的数据和方法,所以,第 3
点和第 4
点的缺点并不存在。
关于第 2
点所描述的二义性问题,这需要从两个方面来看:子类的内部和外部。
从子类内部的角度,由于无需访问父类,所以,多个父类之间即便存在名字冲突,在子类内部也不会造成二义性问题。
而从子类外部来看,如果直接通过子类的实例来调用成员函数,这种二义性确实可能存在。但对于一个 多角色对象 ,所有外部访问都应该是基于 角色 的。而对于每个 角色 ,名字的对应关系是明确的,没有任何二义性。所以,多角色对象特定的访问模式,决定了在外部也不会造成二义性。
至于第 1
点, 菱形继承 带来的两个问题: 数据重复 和 二义性 。
我们首先应该避免不符合我们需要的 菱形继承 。
对于由设计而自然产生的 菱形继承 ,我们无需使用 virtual
继承来避免数据重复。这分为两种情况:
- 基类数据的重复正是每个角色实现的需要。对于每个角色,它确实需要有自己的一份数据拷贝,即便这些数据和另外一个角色是重复的。这些“重复数据”在每个角色那里都有自己的不同状态。另外,由于外部访问是基于某个具体角色的,所以不会造成 二义性 问题。
- 如果基类数据是共享的,那也不应该使用
virtual
继承,而是通过 委托关系 来共享数据。这样,就可以更加合理的避免数据重复。
至于 行为重复 ,由于角色与角色之间的需求是不应该重叠的。所以,对于同一个对象,很难出现两个角色之间有相同的行为子集。如果出现,则说明这两个角色的职责都不单一。将重叠的行为子集定义为一个新的角色,是一个更合理的设计选择。
综上所属,对于多角色对象而言,这种组合方式不会从实质上带来 多重继承 所引起的任何问题。
简化内存管理成本
在开篇时,我们已经提到:很多通信设备,为了避免内存管理所导致的问题:比如碎片化,浪费,以及运行时内存分配失败,会对 领域对象 自定义自己的内存管理器,并在系统加载时,就会预先分配所需的所有内存。如下:
struct Object : Role1 , Role2 , Role3 , Role4 {
void* operator new(size_t);
void free(void* p); private: IMPL_ROLE(Role2);
IMPL_ROLE(Role3); IMPL_ROLE(Role4);
};
namespace {
ObjectAllocator<Object, 500> allocator;
}
void* Object::operator new(size_t) {
return allocator.alloc();
}
void Object::free(void* p) {
return allocator.free(p);
}
但如果系统由于 高内聚低耦合 的方式而导致了很多 小对象 ,就不得不为每个 小对象 都定义自己的内存管理器,并且要按照其最大数量来预先分配,这会随着小对象种类的增多,而大大加重 内存管理 的负担。
另外,在高性能计算领域,为了降低 cache miss rate ,一般的做法是将关联访问数据都尽可能的放在一起。如果分隔为很多小对象,它们都从不同的内存区域进行存放的话,对于性能会造成不同程度的负面影响。
而通过 大对象 的方式,所有的数据最后都聚集在一个对象身上,它们的内存是连续的。这对于 性能及性能优化 都有帮助。
其它问题
数据该怎样存放
数据 按照 高内聚,低耦合 的原则,归属于各个不同的 角色 。然后,角色间根据需要,引用并访问对方的接口。
私有角色
有些角色,纯粹是因为一个对象自身的需要,并不需要公开给外部,则可以通过 private
继承(参见 《The Virtues Of Bastard》 )进行组合。其它角色对它的引用,依然通过 USE_ROLE(Role)
的方式获取。
总结
本文介绍了使用 C++
,在高性能计算领域,内存受限系统下,对于 小类,大对象 实现方式的主要方面。
对于这类系统, 小类,大对象 ,会带来各方面的帮助:
- 清晰:有助于建立与领域清晰映射的领域模型;
- 弹性:在满足性能,空间的约束前提下,遵从 高内聚低耦合 的设计原则,让软件易于理解,易于变化;
- 简单:在满足领域特定约束的前提下,降低了诸多偶发成本。
因此, 小类,大对象 设计模式,成为我们最近几个电信产品的设计的基石。几年前,我当时所在的团队,设计了一款 t-shirt
,表达了 小类,大对象 对于我们那个项目的重要性,以及我们对它的喜爱:
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: 单 Epoll 多线程 IO 模型
下一篇: 不要相信一个熬夜的人说的每一句话
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论