如圖是真實世界實踐中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 這樣的實在是非常拉垮的設計。他們在模板編程中十分有用!
往期推薦


點個在看你最好看
