如图是真实世界实践中C++项目protected继承和private继承的情况:
其中public继承总的平均下来几乎占99.9%,而protected继承几乎没有。private继承还能占极小部分,但也完全可以用public继承+复合取代。
实践是检验真理的唯一标准,
现实世界中的这些项目情况是否能说明protected继承和private继承是没用的废物?是只会出现在语法理论和教科书中的垃圾吗?
作者:IceBear
来源:https://www.zhihu.com/question/425852397/answer/1528656579
〇、引言
既然你所统计的项目里出现了 private 继承和 protected 继承,这不正说明确实有他们的用武之地吗?
一、现有项目分析(以 STL 的三大实现为例)
让我们来康康 C++ 代码的标杆——STL 的源码,是怎么做的:
先来康 GCC 自带的 libstdc++ 的实现:
vector:
list:
deque:
forward_list:
unordered_(multi)set/map 的底层 Hashtable:
tuple 虽然是直接继承自 Tuple_impl:
但 Tuple_impl 是用到了 private 继承展开各个字段的:
pair:
mutex:
functional:
bitset:
再来康 Clang 自带的实现,libc++ 的:
vector:
list:
string:
tuple 底层用于空基类压缩优化的:
其他的类似,我就不继续展开了,否则你这月流量不够了
最后康 MSVC 的:
MSVC STL 虽然几个容器模板没有用到继承,但至少 tuple 和 varient 还是挺给我面子的:
tuple:
varient:
看吧,protected private 继承用的多普遍,更多的我还没列举完~
二、protected private 继承的实际运用场景考察
1)很多人说你用 protected private 继承倒不如用组合,把原本的基类作为一个私有或保护字段。这种论调是很没有道理的。很多时候,继承是替代不了的。比如 C++ 里有一种非常常见的优化技术叫:
空基类压缩优化技术
他就只能用继承去实现;而使用组合时,就没有压缩的效果。
考察下面代码,这是对 vector 压缩 allocator 字段原理的简化实现:
class MyAllocator
{
};
template <typename T, typename Allocator = MyAllocator>
class MyVector: public Allocator
{
};
void use_allocator(const MyAllocator & alloc)
{
}
int main()
{
MyVector<int> vec;
use_allocator(vec);
}
如果 vector 直接 public 继承自 allocator,根据类型兼容原则,在指针和引用语义下,子类同时也可被视作是父类。那 vector 也能被当做 allocator 用了?use_allocator 明明想使用一个分配器,结果居然能接收一个 vector 作为参数?那太恐怖了,语义乱了。
而改成 private 或 protected 继承就不会了:
class MyAllocator
{
};
template <typename T, typename Allocator = MyAllocator>
class MyVector: protected Allocator
{
};
void use_allocator(const MyAllocator & alloc)
{
}
int main()
{
MyVector<int> vec;
use_allocator(vec);
}
这时候编译器会报:
错误:‘MyAllocator’是‘MyVector<int>’不可访问的基类
这就阻止上面的情况发生了。
事实上,整个比较完善的压缩代码是如下的(虽然还是非常的简化了,这里只是说明原理,整个完整的实现代码会非常长):
class MyAllocator
{
};
template <typename Allocator, bool IsEmptyNotFinal =
std::is_empty_v<Allocator> && !std::is_final_v<Allocator>>
class _MyVectorAllocatorCompressedHelper;
template <typename Allocator>
class _MyVectorAllocatorCompressedHelper<Allocator, true>:
protected std::remove_cv_t<Allocator>
{
};
template <typename Allocator>
class _MyVectorAllocatorCompressedHelper<Allocator, false>
{
Allocator alloc;
};
template <typename T, typename Allocator = MyAllocator>
class MyVector: protected _MyVectorAllocatorCompressedHelper<Allocator>
{
};
void use_allocator(const MyAllocator & alloc)
{
}
int main()
{
MyVector<int> vec;
use_allocator(vec);
}
你不要小看上面这个优化技巧,你能用上 3 * sizeof(void*) 字节大小的 std::vector 就得感谢这个技术。
class MyAllocator
{
};
template <typename T, typename Allocator = MyAllocator>
class MyVector
{
T * M_head;
T * M_end;
T * M_capacity;
Allocator alloc;
};
朴素的实现方式里,vector 至少要占 3 个指针大小的空间 + 一个空基类占的一个字节空间,64 位底下就是 3 * 8 + 1 = 25 个字节的大小,再内存对齐一下就得要 32 个字节的大小。但是开了空基类压缩优化以后,只要 24 字节的大小就够了。
(有人说,你不加 allocator 字段不就完了么?不可以!因为从 C++11 开始,Allocator 是允许有状态的,而 gcc-9 及以前所带的 libstdc++ 都尚未支持带状态的 Allocator)
类似的,libstdc++ (gcc-10 以后) 和 libc++ 的实现中,各大容器的 Allocator 字段都是用上面这个原理压缩的。
还有,(multi)set/map 的比较字段,如果是像 std::less 这样的空类,那么就可以压缩掉,如果是函数指针,就给他留个空间:
using namespace std;
template <typename T>
class MyAllocator: public std::allocator<T>
{
char c;
}; // 故意加一个字段使得 MyAllocator 不是空类
int main()
{
cout << sizeof(void*) << endl;
cout << sizeof(MyAllocator<int>) << endl;
cout << sizeof(std::set<int, std::less<int> >) << endl;
cout << sizeof(std::set<int, bool(*)(int, int) >) << endl;
cout << sizeof(std::set<int, bool(*)(int, int), MyAllocator<int> >) << endl;
}
https://gcc.godbolt.org/z/r7orzKgcc.godbolt.org/z/r7orzK
clang11 + libc++ 底下的输出分别是:
8
1
24
32
40
可见压缩效果是非常明显的。
这样的例子比比皆是:
unordered_(multi)set/map 的 Hash 字段,equal_to 字段,也可以用这个方法压缩。
还有 tuple 对空类字段的压缩,也采用了这个手法。(和你手写 struct 略有不同,至少 libstdc++-10 下的 tuple 是有压缩掉空类字段的技术的)
2)既然谈到了 tuple,我们就来考察一下 tuple。
这次我不亲手写代码了,就百度一下,随手找找一篇博客现场打脸好啦。
百度搜“std::tuple 实现”,好,第一篇博客,就属你排名最高,我就来打你脸了。
打开一看:
好的,确实是用常规思路来实现 tuple 的,即:取到第一个模板参数后,作为一个数据成员,然后递归继承 tuple<剩下的模板参数>。STL 也是这么搞的。这份实现没有用到空类成员压缩优化,不过没关系,反正这个优化也不是强制的,而且我现在主要想打脸的是他的 public 继承。
下面,我把他的代码原封不动地照抄过来:
// tuple原始版本
template <typename ... __args_type>
class tuple;
// tuple无参
template <>
class tuple <>
{
public:
tuple() {}
virtual ~tuple() {}
};
// tuple的带参偏特化
template <typename __this_type, typename ... __args_type>
class tuple <__this_type, __args_type ...> : public tuple<__args_type ...>
{
public:
tuple(__this_type val, __args_type ... params) : tuple<__args_type ...>(params ...)
{
value_ = val;
}
virtual ~tuple() {}
__this_type get_value()
{
return value_;
}
public:
// 每一层继承的节点参数剥离
// 将每一节点内容存储至其中
__this_type value_;
};
然后测试代码:
void use_tuple(const ::tuple<float, char> & tuple)
{
}
int main()
{
::tuple<int, float, char> t(0, 1.0, 'a');
use_tuple(t);
}
编译通过。
看到没有,按照类型兼容原则,tuple <int, float, char> 是 tuple<float, char> 的子类,那么就可以当父类去用。握草,本来期待接收二元组参数的函数 use_tuple,接收到的居然是一个三元组。能这样搞你不慌吗?这么低质量的库,你在业务代码里敢用吗?
然后我看了百度搜索结果的第二篇博客,一样辣⬛,也是用的 public 继承。而且这位作者大言不惭地说:
单继承版本确实比多继承版本美得多了
所以这位作者不知道空基类优化呀。
第三篇博客用的 private 继承,可惜文中并没有讲解为什么要用 private 继承。
第四篇只是讲解 tuple 用法的,没有谈实现原理,跳过。
第五篇用了 private 继承,也讲到了空基类优化,非常赞。
。。。
3)下面再谈谈 public 继承下,类型兼容原则的一处大坑:
考察下面代码:
class Base
{
private:
int * resource;
public:
Base() : resource(new int[10])
{
}
virtual ~Base()
{
delete[] resource;
}
Base& operator=(Base && b)
{
delete[] resource;
resource = b.resource;
b.resource = nullptr;
std::cout << "move b" << std::endl;
return *this;
}
};
class Derived1: public Base
{
private:
double * resource2;
public:
Derived1() : resource2(new double[10])
{
}
virtual ~Derived1()
{
delete[] resource2;
}
Derived1& operator=(Derived1 && d)
{
delete[] resource2;
resource2 = d.resource2;
d.resource2 = nullptr;
std::cout << "move d" << std::endl;
return *this;
}
};
int main()
{
Base b;
Derived1 d1;
b = std::move(d1);
}
输出:
move b
如果你有足够的安全意识的话,会意识到:
d1 所持有的资源只被移动了一半!b 只承接了 d1 中继承自 Base 的那部分。而 d1 中 Derived 类所特有的资源 resource2,依然还留在 d1 里!
如果你没意识到资源只被移动一半的话,这将会是大坑~~~就算你后面不会复用已经被 move 了的 d1 (而且 C++ 本来就不建议复用被 move 过的对象),你的析构函数要是没考虑到这种情况,是会 BOOM 的。
三、分析与结论
回顾第二节中我们所考察的三种场景,我们可以看到,类型兼容原则具有不可忽视的缺点——在有些语境下,把子类当父类用完全可以;但是有些语境下把子类当父类用,是会与我们的预期不合的。而 protected private 继承使得基类成为不可访问的基类,就能在不当使用时,产生编译错误,使得问题得以暴露出来,不至于藏着掖着,从而成为隐患。(在编程中,出现问题总比没有问题要好,相信这是各位同仁的共识)
再有,空基类优化的需求,使得必须要用继承来实现他——组合的方式不能起到压缩效果,而 public 继承又会产生奇怪的语义 (比如 2.1 中的例子,vector 居然也能是 allocator),所以就决定了:
protected private 继承绝不是没有用武之地
但是,为什么 Java 等后来的语言可以砍掉 protected private,只留下一种继承方式?
因为 Java 所引入的 package 这个语法机制太牛逼了,他允许你规定 package 中的哪些类是可以对外公开的,即你可以在类前面加 public, protected, private 或者就用默认的可见性修饰。这样,不把基类暴露出去,外面的业务代码不能访问到基类,于是问题就有所缓解了。
与其对比,C++ 的 namespace 做的就非常原始啊,只有一个解决命名冲突的功能,你不能在 namespace 里设置类的可见性。(笔者有精神洁癖,这个功能的缺失,令我在处理不该暴露的基类,比如 XXX_impl,XXX_base,XXX_detail 时,会比较难受)
作为一名模板元黑魔法编程低手,我明确给出结论:private protected 继承在业务代码里用的非常少,但是在模板库里就很有用。
你所引用的数据就是非常好的对比。这里面只有 folly boost 是模板库项目,其他项目都属于业务类。所以 folly boost 里用到 private protected 继承的比例明显就比其他项目高。而且我上面给你列举的三家 STL 的实现里,也大量地出现了他们,这也是很好的佐证。
之所以在模板库中用的多,而在业务代码里用的比较少,我想原因有以下几点:
1)模板库要想好用、通用、用的安全,必须要谨慎地考虑到所有场景 (所以库开发对编程人员的要求是比对从事一般开发人员的要求要高的,问题要考虑的非常全面)。我上面论述的那些例子,就是离开 protected private 继承后,不好用、不安全的。
2)业务代码在使用继承时,往往只是利用一下多态性,更确切地说,就是只要一个父类表示,但是却能产生不同的效果。
比如经典的不能在经典的例子:
Animal * animal = new Dog();
animal->shout();
业务代码基本上都是这样需要类型兼容的多,而对通用性考虑的要少——只要我的这个具体的需求能解决、不出问题就行了,其他的考虑的没模板库多。
3)纯粹是大家都懒,不想考虑那么多业务以外的问题就是了。public 继承改成 private 继承后,很多能访问的成员访问不到了,还得写一层转发,麻烦事。而且很多人甚至都不知道有“用 using 可以修改成员可见性”这种很方便的语法。
https://en.cppreference.com/w/cpp/language/using_declaration#In_class_definitionen.cppreference.com/w/cpp/language/using_declaration#In_class_definition
btw. 说句题外话,很多人吐槽 C++ 特性多,过于复杂。实际上这些人平常只是在做业务开发,不知道这么多特性其实都是给库作者用的。其实写业务代码 (包括我在写业务代码时) 都是用不到多少高级特性的,很多语法也是很冷门的 (protected private 继承就是),不学都可以的。但是对于模板库的开发来说,化用毛子的一句话——
C++ 特性虽多,但没有一个是多余的Using-declaration - cppreference.combtw. 说句题外话,很多人吐槽 C++ 特性多,过于复杂。实际上这些人平常只是在做业务开发,不知道这么多特性其实都是给库作者用的。其实写业务代码 (包括我在写业务代码时) 都是用不到多少高级特性的,很多语法也是很冷门的 (protected private 继承就是),不学都可以的。但是对于模板库的开发来说,化用毛子的一句话——
C++ 特性虽多,但没有一个是多余的
四、结尾语
Bjarne 当年是在大名鼎鼎的贝尔实验室开始的编程语言革命探索,不但直接成果——C++ 语言一跃成为应用十分广泛的语言,一直到今天流行了四十多年;而且不少的设计也深远地影响了后来的若干门非常流行的编程语言——Java C# D Rust 等,甚至就连老祖宗 C 都回抄了 C++ 的不少特性。
作为最初的一批尝试探索面向对象思想的革命者,C++ 本来就没有后辈语言那样优渥的历史条件。偶有一两个点未能预见后世的发展趋势,也实属正常。再说了,private protected 继承只是实践中运用的相对较少而已,但他们绝不是像 vector<bool>, auto_ptr 这样的实在是非常拉垮的设计。他们在模板编程中十分有用!
往期推荐
点个在看你最好看