C++標(biāo)準(zhǔn)庫中的協(xié)變與逆變

如果類型 Car 是類型 Vehicle 的子類型(subtype秽誊,CarVehicle,可以在任何出現(xiàn) Vehicle 的地方用 Car 代替)课舍,那么關(guān)于 CarVehicle 的復(fù)雜類型(如 std::vector<Car>std::vector<Vehicle>)之間的關(guān)系如下:

  • std::vector<Car>std::vector<Vehicle> 的子類型坛猪,所有出現(xiàn) std::vector<Vehicle> 的地方都可以用 std::vector<Car> 代替,即代替方向一致拇派,則稱之為協(xié)變(covariance)。
  • std::vector<Vehicle>std::vector<Car> 的子類型本股,所有出現(xiàn) std::vector<Car> 的地方都可以用 std::vector<Vehicle> 代替攀痊,即代替方向相反桐腌,則稱之為逆變(cotravariance)拄显。
  • std::vector<Vehicle>std::vector<Car> 之間沒有關(guān)系,則稱之為不變(invariance)案站。

當(dāng)我們深入模板的時候躬审,協(xié)變和逆變這兩個概念就會經(jīng)常地出現(xiàn)。如果一個語言設(shè)計者要想設(shè)計一個支持參數(shù)化多態(tài)(例如,C++ 中模板承边,Java 和 C# 中泛型)的語言遭殉,那么他就必須在協(xié)變,逆變和不變中做出選擇博助∠瘴郏看下面的例子

class Vehicle { };
class Car : public Vehicle { };

std::vector<Vehicle> vehicles;
std::vector<Car> cars;

vehicles = cars; // ERROR, cars 不能代替 vehicles
cars = vehicles; // ERROR, vehicles 不能代替 cars

上述代碼無法通過編譯,cars 無法代替 vehicles富岳,vehicles 也無法代替 cars蛔糯,在此時表現(xiàn)出來的是不變。
每一次模板被實例化窖式,編譯器都會創(chuàng)建一個全新的類型蚁飒;雖然 carsvehicles 實例化了同一個模板,但是他們是兩個完全不同的類型萝喘,之間沒有任何關(guān)系淮逻。在 C++ 中,兩個沒有關(guān)系的用戶自定義類型默認(rèn)是無法彼此相互賦值的阁簸,但是只要我們定義合適的復(fù)制構(gòu)造函數(shù)或者賦值操作符爬早,就可以實現(xiàn)協(xié)變或者逆變。 std::vector 由于沒有實現(xiàn)這樣的復(fù)制構(gòu)造函數(shù)和賦值操作符启妹,因此表現(xiàn)出來的是不變凸椿。
不變只是其中的一種選擇,其他的選擇未必是錯誤的翅溺。事實上脑漫,對于指針和引用,C++ 選擇了協(xié)變咙崎,例如 Car* 可以賦值給 Vehicle* 优幸,更為準(zhǔn)確地說,由于 CarVehicle褪猛,則編譯器允許在 Vehicle* 出現(xiàn)的地方由 Car* 來代替网杆。

template <typename T>
using Pointer = T*;

Pointer<Vehicle> vehicle;
Pointer<Car> car;

vehicle = car; // OK, car 可以代替 vehicle,即在使用 vehicle 的時候?qū)嶋H上是用的是 car

上述代碼中伊滋,我們利用 Pointer 表示指針碳却,這種表示方法是為了讓本文協(xié)調(diào)起來,重點討論處理模板時的情況笑旺。這種表示方法不會造成其他副作用昼浦。
那么,當(dāng)在處理模板時筒主,我們應(yīng)該在模板實例化的類之間選擇怎樣的關(guān)系呢关噪?

  • 首選是不變鸟蟹,也就是說,當(dāng) CarVehicle 的模板實例化之間是沒有任何關(guān)系的使兔。這是 C++ 的默認(rèn)選擇建钥。
  • 其次的選擇是協(xié)變,即模板實例化之間的關(guān)系與模板參數(shù)之間的關(guān)系是一致的虐沥。例如熊经,std::shared_ptrstd::unique_ptr 等欲险,為了使他們表現(xiàn)更像普通的指針奈搜,應(yīng)該選擇協(xié)變。這種情況不是 C++ 默認(rèn)的盯荤,我們需要編寫合適的復(fù)制構(gòu)造函數(shù)和賦值操作符馋吗。
  • 最后的選擇是逆變,模板實例化之間的關(guān)系與模板參數(shù)之間的關(guān)系是顛倒的秋秤。在接下來的部分將會討論到逆變宏粤。

協(xié)變

通過協(xié)變,模板實例化保留了模板參數(shù)之間的關(guān)系灼卢,即所有出現(xiàn) TEMPLATE<Vehicle> 的地方都可以用 TEMPLATE<Car> 來代替绍哎。
在 C++ 標(biāo)準(zhǔn)庫中有如下常見的例子,

std::shared_ptr<Vehicle> shptr_vehicle;
std::shared_ptr<Car> shptr_car;
shptr_vehicle = shptr_car; // OK鞋真,shptr_car 可以代替 shptr_vehicle
shptr_car = shptr_vehicle; // ERROR

std::unique_ptr<Vehicle> unique_vehicle;
std::unique_ptr<Car> unique_car;
unique_vehicle = std::move(unique_car); // OK
unique_car = std::move(unique_vehicle); // ERROR

可以看得出來崇堰,std::shared_ptr 表現(xiàn)的和普通指針是一樣,子類的指針可以賦值給父類的指針涩咖。
下面是標(biāo)準(zhǔn)庫中常見的模板類型海诲,

Type Covariant Contravariant
STL containers No No
std::initializer_list<T> No No
std::future<T> No No
std::optional<T> No No
std::shared_ptr<T> Yes No
std::unique_ptr<T> Yes No
std::pair<T, U> Yes No
std::tuple<T, U> Yes No
std::atomic<T> Yes No
std::function<R (T)> Yes (in return) Yes (in arguments)

其中我們需要注意的是,標(biāo)準(zhǔn)庫中的所有容器都是不變的檩互,即使容器包含的是指針特幔,例如 std::vector<Car*>
接下來我們討論一下闸昨, std::shared_ptr 是如何實現(xiàn)協(xié)變的蚯斯。

template <typename _Tp>
class shared_ptr
{
public:
  // 將賦值構(gòu)造函數(shù)設(shè)置為模板,此時shared_ptr具有了隱式類型轉(zhuǎn)換的能力
  // 第二個模板參數(shù)的意義是饵较,檢測這種隱式轉(zhuǎn)換是否合理
  // 需要注意的是 _Tp1 和 _Tp 一定是不同的類型拍嵌,即標(biāo)準(zhǔn)庫此外必須另提供正常的復(fù)制構(gòu)造函數(shù)
  template <typename _Tp1,
            typename = typename std::enable_if<std::is_convertiable<_Tp1*,_Tp*>::value>::type> 
  shared_ptr(const shared_ptr<_Tp1>& __r) noexcept
            // 注意 __r 與本實例是不同的類型,訪問數(shù)據(jù)需要 friend
      : _M_ptr(__r._M_ptr) { }

  // 賦值操作符
  template <typename _Tp1>
  shared_ptr& operator=(const shared_ptr<_Tp1> __r) noexcept {
    _M_ptr = __r._M_ptr;
    return *this;
  }

private:
  // 盡管因模板參數(shù)不同而產(chǎn)生不同的模板實例循诉,彼此可以相互訪問 _M_ptr
  template <typename _Tp1> friend class shared_ptr;

  _Tp*  _M_ptr;
};

以上是經(jīng)過簡化的 GCC 中 std::shared_ptr 的代碼横辆。之所以能夠?qū)崿F(xiàn)協(xié)變,是因為 std::shared_ptr 這兩個特殊的復(fù)制構(gòu)造函數(shù)和賦值操作符是模板成員函數(shù)(template member function)打洼,也就是說在構(gòu)造或者賦值一個 std::shared_ptr 的時候可以接受其他不同實例化的 std::shared_ptr龄糊,這樣子也就實現(xiàn)了隱式轉(zhuǎn)換逆粹。
但是請注意募疮,在 GCC 的實現(xiàn)中炫惩,賦值操作符沒有復(fù)制構(gòu)造函數(shù)的第二個模板參數(shù),沒有檢查兩個指針是否能夠相互轉(zhuǎn)換阿浓。我對于這一點有疑惑他嚷,目前還沒有明白為什么這么設(shè)計。而在 cppreference 和 boost::shared_ptr 中芭毙,賦值構(gòu)造函數(shù)和賦值操作符都沒有第二個模板參數(shù)筋蓖,但是 boost::shared_ptr 中強(qiáng)調(diào)了,對于復(fù)制構(gòu)造函數(shù)退敦,兩個參數(shù)的指針必須要滿足能夠相互轉(zhuǎn)化的條件粘咖,而對于賦值操作符,卻沒有這樣的強(qiáng)調(diào)侈百。我認(rèn)為瓮下,對兩個指針相互轉(zhuǎn)換的檢測發(fā)生在編譯期,即使沒有檢測钝域,到了最底層依然需要將其中的一個指針賦值給另一個指針讽坏,此時如果兩個指針不兼容,則會發(fā)生編譯期錯誤例证,所以沒有第二個模板參數(shù)依然可行路呜。如果大家有其他的想法,可以在評論區(qū)討論织咧。

到目前為止胀葱,我們討論了對于 CarVehicle 的一層包裝(std::shared_ptr<Car>std::shared_ptr<Vehicle>),我們還可以看看多層包裝的情況笙蒙,

std::tuple<std::atomic<Vehicle>>  tuple_atomic_vehicle;
std::tuple<std::atomic<Car>> tuple_atomic_car;

tuple_atomic_vehicle = tuple_atomic_car; // OK
tuple_atomic_car = tuple_atomic_vehicle; // ERROR

std::tuple<std::atomic<Vehicle*>> tuple_atomic_ptrvehicle;
std::tuple<std::atomic<Car*>> tuple_atomic_ptrcar;

tuple_atomic_ptrvehicle = tuple_atomic_ptrcar; // OK
tuple_atomic_ptrcar = tuple_atomic_ptrvehicle; // ERROR

從上邊的例子中巡社,我們看似很容易得到這樣的結(jié)論:幾個具有協(xié)變特性的模板的復(fù)合依然是協(xié)變的。由于 Car*Vehicle* 之間的關(guān)系是協(xié)變手趣,又由于 std::atomic 滿足協(xié)變特性晌该,則 std::atomic<Car*>std::atomic<Vehicle*> 之間的關(guān)系也是協(xié)變,進(jìn)而 std::tuple<std::atomic<Car*>>std::tuple<std::atomic<Vehicle*>> 之間的關(guān)系也是協(xié)變绿渣。
那么我們看下面的例子朝群,

std::shared_ptr<std::tuple<Vehicle>> shptr_tuple_vehicle;
std::shared_ptr<std::tuple<Car>> shptr_tuple_car;

shptr_tuple_vehicle = shptr_tuple_car; // ERROR, cannot convert ‘std::tuple<Car>* const’ to 
                                       // ‘std::tuple<Vehicle>*’
shptr_tuple_car = shptr_tuple_vehicle; // ERROR, cannot convert ‘std::tuple<Vehicle>* const’
                                       // to ‘std::tuple<Car>*’

由于 std::shared_ptr<_Tp> 保存的是 _Tp 的指針 _M_ptr,所以在賦值的時候中符,會在 std::tuple<Car>*std::tuple<Vehicle>* 之間進(jìn)行賦值姜胖,又由于協(xié)變的實現(xiàn)是通過編寫合適的復(fù)制構(gòu)造函數(shù)和賦值操作符,而 std::tuple<Car>std::tuple<Vehicle> 兩者之間并沒有繼承關(guān)系淀散,所以會發(fā)生錯誤右莱。
因此蚜锨,我們在寫多層包裝的時候,一定要小心慢蜓,對于每一個模板都要盡量了解內(nèi)部實現(xiàn)亚再。


逆變

我們假設(shè) TEMPLATE<T> 滿足逆變,則所有出現(xiàn) TEMPLATE<Car> 都可以用 TEMPLATE<Vehicle> 代替晨抡,這樣子的表達(dá)不直觀氛悬,而且很容易發(fā)生運行時錯誤。所以逆變的應(yīng)用范圍十分地有限耘柱。
在介紹逆變應(yīng)用之前如捅,我們先看看 C++ 中的一個的特性:返回值協(xié)變(covariant return types)。

class VehicleFactory
{
public:
  virtual Vehicle* create() const { return new Vehicle; }
  virtual ~VehicleFactory() = default;
};

class CarFactory : public VehicleFactory
{
public:
  Car* create() const override { return new Car; }
};

VehicleFactory::create 的返回值是 Vehicle* 调煎,而 CarFactory::create 的返回值是 Car* 镜遣; CarFactory::create 可以代替 VehicleFactory::create(重寫), Car* 代替 Vehicle* 士袄,代替方向一致悲关,即稱為返回值協(xié)變。
假如我們把函數(shù) create 返回的原生指針改成 std::shared_ptr窖剑,上邊的代碼是否還成立坚洽?

class VehicleFactory
{
public:
  virtual std::shared_ptr<Vehicle> create() const { return std::shared_ptr<Vehicle>{ new Vehicle }; }
  virtual ~VehicleFactory() = default;
};

class CarFactory : public VehicleFactory
{
public:
  // ERROR, invalid covariant return type
  std::shared_ptr<Car> create() const override { return std::shared_ptr<Car>{ new Car }; }
};

如上所示,不成立西土。因為 std::shared_ptr 的協(xié)變能力是通過編寫代碼實現(xiàn)的讶舰,當(dāng) std::shared_ptr<Car> 賦值給 std::shared_ptr<Vehicle> 時調(diào)用了復(fù)制構(gòu)造函數(shù)或者賦值操作符,而在這里沒有賦值動作(即沒有函數(shù)可以調(diào)用)需了,編譯器只認(rèn)定語言內(nèi)置的指針和引用具有協(xié)變能力跳昼。
對于重寫的函數(shù),返回值是協(xié)變的肋乍,那參數(shù)呢鹅颊?我們從重寫的角度考慮,CarFactory::create 要接受 VehicleFactory::create 的實參墓造,那么后者簽名中的參數(shù)類型應(yīng)該是前者簽名中參數(shù)類型的子類型(或者相同的類型)堪伍,

class Metal { };
class Iron : public Metal { };

class VehicleFactory
{
public:
  virtual Vehicle* create(Iron*) const { return new Vehicle; }
  virtual ~VehicleFactory() = default;
};

class CarFactory : public VehicleFactory
{
public:
  // ERROR,沒有重寫卻被標(biāo)記為 override
  // 即使沒有關(guān)鍵字 override 觅闽,也隱藏了父類的函數(shù) create
  Car* create(Metal*) const override { return new Car; }
};

上述寫法是錯誤的帝雇。我們拋開 C++ 對于重寫的限制,就從邏輯上來講蛉拙,VehicleFactory::create 的參數(shù)類型可以是 CarFactory::create 的子類型尸闸。如下圖,

C++ 沒有內(nèi)置的對參數(shù)逆變的支持,那么我們應(yīng)該如何實現(xiàn)呢吮廉?

std::function

看如下的例子苞尝,

template <typename T>
using Sink = std::function<void (T*)>;

Sink<Vehicle> vehicle_sink = [] (Vehicle*) { std::cout << "Got some vehicle\n"; };
Sink<Car> car_sink = vehicle_sink; // OK, vehicle_sink可以代替 car_sink
car_sink(new Car);

vehicle_sink = car_sink; // ERROR

car_sink 是一個接受參數(shù)類型為 Car* 且什么都沒有返回的函數(shù), vehicle_sink 是一個接受參數(shù)類型為 Vehicle* 且什么都沒有返回的函數(shù)宦芦;由于 vehicle_sink 可以代替 car_sink宙址,而且 Car* 可以代替 Vehicle*,代替方向相反踪旷,因此 Sink 實現(xiàn)了參數(shù)逆變曼氛。
std::function 也實現(xiàn)了返回值協(xié)變豁辉,

std::function<Car* (Metal*)> car_factory =
    [] (Metal*) {
                    std::cout << "Got some Metal\n";
                    return new Car;
                };

std::function<Vehicle* (Iron*)> vehicle_factory = car_factory;

Vehicle* some_vehicle = vehicle_factory(new Iron); // OK

而且令野,對于 std::shared_ptrstd::function 的返回值協(xié)變和參數(shù)逆變依然成立徽级,

std::cout << std::boolalpha
          << std::is_convertible<
                  std::function<std::shared_ptr<Car> (std::shared_ptr<Vehicle>)>,
                  std::function<std::shared_ptr<Vehicle> (std::shared_ptr<Car>)>>::value
          << '\n'; // OK气破,打印結(jié)果為“ true ”

這是由于 std::function 有特殊的復(fù)制構(gòu)造函數(shù)和賦值操作符。


其他

一些語言中函數(shù)的可變性

參數(shù) 返回值
C++ (since 1998), Java (since J2SE 5.0), Scala, D 不變 協(xié)變
C# 不變 不變
Sather 逆變 協(xié)變
Eiffel 協(xié)變 協(xié)變

繼承與可變性

我們在討論可變性(variance)的時候餐抢,涉及到的都是子類型(subtype)现使,即如果 CarVehicle 的子類型,則所有出現(xiàn) Vehicle 的地方都可以用 Car 代替旷痕。而在 C++ 中碳锈,實現(xiàn)子類型的 根源 是繼承,即如果 CarVehicle 的子類(subclass)欺抗,則 CarVehicle 的子類型售碳,但是并非所有具有子類型關(guān)系的類就一定具有繼承關(guān)系,例如 std::tuple<Car>std::tuple<Vehicle> 之間就沒有繼承關(guān)系绞呈。

引用與可變性

引用和指針都可以實現(xiàn)多態(tài)贸人,但是我們經(jīng)常使用的是指針而忽略引用的作用,接下來將從可變性的角度探討引用發(fā)揮的作用佃声。
前面我們一直討論的是艺智,對于兩個具有子類型關(guān)系的類,他們的復(fù)雜類型之間可變性圾亏。我們討論了利用復(fù)制構(gòu)造函數(shù)和賦值操作符來實現(xiàn) std::shared_ptr<Car> 代替 std::shared_ptr<Vehicle>十拣,卻沒有討論如何實現(xiàn) Car 代替 Vehicle

class Vehicle
{
public:
    Vehicle() { }
    ~Vehicle() = default;
    Vehicle(const Vehicle&) = default;
    Vehicle& operator=(const Vehicle&) { return *this; }
};

class Car : public Vehicle
{
public:
    Car() { }
    ~Car() = default;
    Car(const Car&) = default;
    Car& operator=(const Car&) { return *this; }
};

Car car { };
Vehicle vehicle = car; // OK志鹃,調(diào)用復(fù)制構(gòu)造函數(shù)夭问, car 可以代替 vehicle

依然利用的是復(fù)制構(gòu)造函數(shù)和賦值操作符。
在最后一行處弄跌,調(diào)用了 Vehicle 的復(fù)制構(gòu)造函數(shù) Vehicle(const Vehicle&) { } 甲喝,而實參是 Vehicle 的子類 Car 的實例 car,這里就用到了引用的多態(tài)性铛只,該性質(zhì)是可變性的基礎(chǔ)埠胖,不是或有或無的糠溜。


參考

[1] Covariance and Contravariance in C++ Standard Library
[2] Covariance and contravariance (computer science)
[3] std::shared_ptr
[4] boost::shared_ptr
[5] More C++ Idioms/Covariant Return Types
[6] Subtyping

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市直撤,隨后出現(xiàn)的幾起案子非竿,更是在濱河造成了極大的恐慌,老刑警劉巖谋竖,帶你破解...
    沈念sama閱讀 221,406評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件红柱,死亡現(xiàn)場離奇詭異,居然都是意外死亡蓖乘,警方通過查閱死者的電腦和手機(jī)锤悄,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,395評論 3 398
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來嘉抒,“玉大人零聚,你說我怎么就攤上這事⌒┦蹋” “怎么了隶症?”我有些...
    開封第一講書人閱讀 167,815評論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長岗宣。 經(jīng)常有香客問我蚂会,道長,這世上最難降的妖魔是什么耗式? 我笑而不...
    開封第一講書人閱讀 59,537評論 1 296
  • 正文 為了忘掉前任胁住,我火速辦了婚禮,結(jié)果婚禮上纽什,老公的妹妹穿的比我還像新娘措嵌。我一直安慰自己,他們只是感情好芦缰,可當(dāng)我...
    茶點故事閱讀 68,536評論 6 397
  • 文/花漫 我一把揭開白布企巢。 她就那樣靜靜地躺著,像睡著了一般让蕾。 火紅的嫁衣襯著肌膚如雪浪规。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,184評論 1 308
  • 那天探孝,我揣著相機(jī)與錄音笋婿,去河邊找鬼。 笑死顿颅,一個胖子當(dāng)著我的面吹牛缸濒,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 40,776評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼庇配,長吁一口氣:“原來是場噩夢啊……” “哼斩跌!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起捞慌,我...
    開封第一講書人閱讀 39,668評論 0 276
  • 序言:老撾萬榮一對情侶失蹤耀鸦,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后啸澡,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體袖订,經(jīng)...
    沈念sama閱讀 46,212評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,299評論 3 340
  • 正文 我和宋清朗相戀三年嗅虏,在試婚紗的時候發(fā)現(xiàn)自己被綠了洛姑。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,438評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡旋恼,死狀恐怖吏口,靈堂內(nèi)的尸體忽然破棺而出奄容,到底是詐尸還是另有隱情冰更,我是刑警寧澤,帶...
    沈念sama閱讀 36,128評論 5 349
  • 正文 年R本政府宣布昂勒,位于F島的核電站蜀细,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏戈盈。R本人自食惡果不足惜奠衔,卻給世界環(huán)境...
    茶點故事閱讀 41,807評論 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望塘娶。 院中可真熱鬧归斤,春花似錦、人聲如沸刁岸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,279評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽虹曙。三九已至迫横,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間酝碳,已是汗流浹背矾踱。 一陣腳步聲響...
    開封第一講書人閱讀 33,395評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留疏哗,地道東北人呛讲。 一個月前我還...
    沈念sama閱讀 48,827評論 3 376
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親贝搁。 傳聞我的和親對象是個殘疾皇子刃宵,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,446評論 2 359