C++ protected 繼承和 private 繼承是不是沒用的廢物?

2021-12-09 程式設計師的那些事

如圖是真實世界實踐中C++項目protected繼承和private繼承的情況:

其中public繼承總的平均下來幾乎佔99.9%,而protected繼承幾乎沒有。private繼承還能佔極小部分,但也完全可以用public繼承+複合取代。

實踐是檢驗真理的唯一標準,現實世界中的這些項目情況是否能說明protected繼承和private繼承是沒用的廢物?是只會出現在語法理論和教科書中的垃圾嗎?

〇、引言

既然你所統計的項目裡出現了 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』不可訪問的基類
這就阻止上面的情況發生了。

事實上,整個比較完善的壓縮代碼是如下的(雖然還是非常的簡化了,這裡只是說明原理,整個完整的實現代碼會非常長):

#include <type_traits>
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 這樣的空類,那麼就可以壓縮掉,如果是函數指針,就給他留個空間:

#include <set>#include <iostream>
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 繼承下,類型兼容原則的一處大坑:考察下面代碼:

#include <iostream>
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 這樣的實在是非常拉垮的設計。他們在模板編程中十分有用!

相關焦點

  • c++虛繼承,多繼承
    在面向對象的程序設計中,繼承和多重繼承一般指公共繼承。在無繼承的類中,protected 和 private 控制符是沒有差別的,在繼承中,基類的 private 對所有的外界都屏蔽(包括自己的派生類), 基類的 protected 控制符對應用程式是屏蔽的, 但對其派生類是可訪問的。
  • 每日乾貨丨C++入門知識,C++繼承和派生詳解
    從類的成員角度看, 派生類自動地將基類的所有成員作為自己的成員, 這叫做」繼承」. 基類和派生類也可以叫做」父類」和」子類」, 也可以叫做」一般類」和」特殊類」.類的繼承是指派生類繼承基類的數據成員和成員函數.
  • C++ 類、繼承、重載《Rice C++ 學習開發》
    繼承允許我們依據另一個類來定義一個類,這使得創建和維護一個應用程式變得更容易。這樣做,也達到了重用代碼功能和提高執行時間的效果。C++支持多繼承。一個派生類繼承了所有的基類方法,但下列情況除外:1.基類的構造函數、析構函數和拷貝構造函數。
  • Java基礎:封裝與繼承
    Java是通過訪問控制關鍵字來實現的信息隱藏的,一共有三個關鍵字:public、protected和private。關鍵字可用於修飾類,或者修飾類中的成員變量和成員方法。用於修飾類用於修飾成員變量和成員方法:public:其他任何類都可以訪問該成員protected:只有繼承自己的子類才能訪問該成員private:除自己外其他任何類都不能訪問該成員默認情況,如果沒有任何訪問控制修飾符,則表示相同包內的類可以訪問該成員可見,如果要隱藏一個類的成員變量,只要在該成員變量的前面加上
  • C++、java 和 C 的區別
    的區分 ,這個跟java和c# 的區別比較大,但c#裡面有unit ulong ushort 這三種就相當於c++的修飾詞unsigned,當c++李明的變量類型定義unsigned,就默認是整數。2.java和c#裡面都有字符串型 和byte型, 但c++裡面沒有,但它是以另外的形式存儲這類型的數據的,比如 java和c#裡面的 byte其實就是unsigned char類型;c++中字符數組就能存儲字符串 (char a[]={"hello"}; ps:注意c++裡面定義數組 變量必須在中括號前面)。
  • Java 中的繼承和多態(深入版)
    在這三個特性中,如果沒有封裝和繼承,也不會有多態。那麼多態實現的途徑和必要條件是什麼呢?以及多態中的重寫和重載在JVM中的表現是怎麼樣?在Java中是如何展現繼承的特性呢?對於子類繼承於父類時,又有什麼限制呢?本文系基礎,深入淺出過一遍 Java 中的多態和繼承。多態是同一個行為具有多個不同表現形式或形態的能力。
  • 自學C井之路4——面向對象-繼承
    class Vertebrata{//私有成員private double weight; //體重private double temperature; //體溫//哺乳動物類class Mammal : Vertebrata{//私有成員private string arms; //前肢private string legs
  • Java訪問控制修飾符詳解(public、 private、protected 和 friendly)
    訪問控制符是一組限定類、屬性或方法是否可以被程序裡的其他部分訪問和調用的修飾符。類的訪問控制符只能是空或者 public,方法和屬性的訪問控制符有 4 個,分別是 public、 private、protected 和 friendly,其中 friendly 是一種沒有定義專門的訪問控制符的默認情況。訪問控制修飾符的權限如表 1 所示。
  • 繼承、接口與多態的相關問題
    繼承而得到的類稱為子類,被繼承的類稱為父類。子類不能繼承父類中訪問權限為private的成員變量和方法。子類可以重寫父類的方法,及命名與父類同名的成員變量。但Java不支持多重繼承,即一個類從多個超類派生的能力。
  • c++是如何實現多繼承帶來的多態問題?
    有虛函數的類才能叫多態類型的類,可以從探索虛函數是如何實現動態綁定的來了解如何實現多繼承中的多態。單繼承時虛函數動態綁定的實現原理每個類各有一個虛表(虛函數表),虛表的內容是由編譯器安排的。c++語言並沒有規定虛函數表的內容。
  • 「萬字圖文」Java繼承詳解
    主要分為以下兩類:這裡訪問修飾符主要講解public,protected,default,private四種訪問控制修飾符。非訪問修飾符這裡就介紹static修飾符,final修飾符和abstract修飾符。
  • 淺析 Python 的類、繼承和多態
    __class__<class 'type'>Point 是 type 的一個實例,這和 p 是 Point 的一個實例是一回事。現添加方法 set:class Point:    ...前綴,否則就變成類的屬性(相當於 C++ 靜態成員),而不是對象的屬性了。訪問控制Python 沒有 public / protected / private 這樣的訪問控制,如果你非要表示「私有」,習慣是加雙下劃線前綴。
  • 史上最姨母級Java繼承萬字圖文詳解
    主要分為以下兩類:這裡訪問修飾符主要講解public,protected,default,private四種訪問控制修飾符。非訪問修飾符這裡就介紹static修飾符,final修飾符和abstract修飾符。
  • 「MoreThanJava」Day 5:面向對象進階——繼承詳解
    更多細節protected 關鍵字如果類中創建的變量或者方法使用 protected 描述,則指明了 "就類用戶而言,這是 private 的,但對於任何繼承於此類的導出類或者任何位於同一個 包內的類來說,它是可以訪問的"。
  • 今年起,房產繼承有新規,「不孝子女」沒法繼承房子了,吵也沒用
    對於2021年來說,今年算是比較特別的一年,因為今年有不少新規的實施,尤其是伴隨著《民法典》的實施,婚姻法、繼承法、合同法以及物權法,各種與群眾生活息息相關的新規都開始施行。那麼其中我相信大家最為關心的還是對於房子的繼承問題,畢竟在如今高房價的社會,對於大部分人來說,房子肯定是最為重視的東西。如果能繼承到房子留下來的房子,最起碼不用背負30年房貸,做一個房奴。
  • Java vs C++:子類覆蓋父類函數時縮小可訪問性的不同設計
    所謂「可訪問性」,就是使用 public 、protected、private 等訪問控制符進行修飾,用來控制函數能否被訪問到。子類對父類函數覆蓋時,擴大可訪問性,通常都不是問題。本文要講的是,當子類對父類函數覆蓋的可訪問性縮小時,Java 和 C++ 採取了不同的策略。
  • 2021年房產繼承新規:獨生子女「不一定繼承」父母房產?鬧也沒用
    針對財產的繼承,我們國家也是有一套自己的法律體系的。父輩的遺產繼承,子女不過是第二繼承人。配偶才是遺產繼承的''正統''。如果配偶去世或者自願放棄遺產繼承權,那才能輪到子女。隨著我們的腰包鼓了起來,很多人的生活就豐富了起來,而且財產也就更多了,不過這隨之而來的是財產糾紛問題。
  • Java 8 默認方法和多繼承
    以前經常談論的Java對比c++的一個優勢是Java中沒有多繼承的問題。
  • C#類之間存在繼承關係,什麼是繼承?如何實現類與類之間的繼承?
    C#核心開發-第15單元-接口和繼承-第1節:繼承C#核心開發-接口和繼承1. 繼承現實生活當中,存在很多與繼承相關的現象,如下圖所示:汽車是個大分類,所有轎車和SUV都具有汽車的特徵,如4個輪子,2個後視鏡,1個方向盤。(3). 轎車和SUV再向下分類,細分到具體的車型。(4). 車型還可以繼續向下分類。(5). 轎車和SUV都具有汽車的特徵,但汽車不一定具備轎車和SUV的特徵。