C++标准库中的协变与逆变

如果类型 Car 是类型 Vehicle 的子类型(subtype,CarVehicle,可以在任何出现 Vehicle 的地方用 Car 代替),那么关于 CarVehicle 的复杂类型(如 std::vector<Car>std::vector<Vehicle>)之间的关系如下:

  • std::vector<Car>std::vector<Vehicle> 的子类型,所有出现 std::vector<Vehicle> 的地方都可以用 std::vector<Car> 代替,即代替方向一致,则称之为协变(covariance)。
  • std::vector<Vehicle>std::vector<Car> 的子类型,所有出现 std::vector<Car> 的地方都可以用 std::vector<Vehicle> 代替,即代替方向相反,则称之为逆变(cotravariance)。
  • std::vector<Vehicle>std::vector<Car> 之间没有关系,则称之为不变(invariance)。

当我们深入模板的时候,协变和逆变这两个概念就会经常地出现。如果一个语言设计者要想设计一个支持参数化多态(例如,C++ 中模板,Java 和 C# 中泛型)的语言,那么他就必须在协变,逆变和不变中做出选择。看下面的例子

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 无法代替 vehiclesvehicles 也无法代替 cars,在此时表现出来的是不变。
每一次模板被实例化,编译器都会创建一个全新的类型;虽然 carsvehicles 实例化了同一个模板,但是他们是两个完全不同的类型,之间没有任何关系。在 C++ 中,两个没有关系的用户自定义类型默认是无法彼此相互赋值的,但是只要我们定义合适的复制构造函数或者赋值操作符,就可以实现协变或者逆变。 std::vector 由于没有实现这样的复制构造函数和赋值操作符,因此表现出来的是不变。
不变只是其中的一种选择,其他的选择未必是错误的。事实上,对于指针和引用,C++ 选择了协变,例如 Car* 可以赋值给 Vehicle* ,更为准确地说,由于 CarVehicle,则编译器允许在 Vehicle* 出现的地方由 Car* 来代替。

template <typename T>
using Pointer = T*;

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

vehicle = car; // OK, car 可以代替 vehicle,即在使用 vehicle 的时候实际上是用的是 car

上述代码中,我们利用 Pointer 表示指针,这种表示方法是为了让本文协调起来,重点讨论处理模板时的情况。这种表示方法不会造成其他副作用。
那么,当在处理模板时,我们应该在模板实例化的类之间选择怎样的关系呢?

  • 首选是不变,也就是说,当 CarVehicle 的模板实例化之间是没有任何关系的。这是 C++ 的默认选择。
  • 其次的选择是协变,即模板实例化之间的关系与模板参数之间的关系是一致的。例如,std::shared_ptrstd::unique_ptr 等,为了使他们表现更像普通的指针,应该选择协变。这种情况不是 C++ 默认的,我们需要编写合适的复制构造函数和赋值操作符。
  • 最后的选择是逆变,模板实例化之间的关系与模板参数之间的关系是颠倒的。在接下来的部分将会讨论到逆变。

协变

通过协变,模板实例化保留了模板参数之间的关系,即所有出现 TEMPLATE<Vehicle> 的地方都可以用 TEMPLATE<Car> 来代替。
在 C++ 标准库中有如下常见的例子,

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 表现的和普通指针是一样,子类的指针可以赋值给父类的指针。
下面是标准库中常见的模板类型,

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)

其中我们需要注意的是,标准库中的所有容器都是不变的,即使容器包含的是指针,例如 std::vector<Car*>
接下来我们讨论一下, std::shared_ptr 是如何实现协变的。

template <typename _Tp>
class shared_ptr
{
public:
  // 将赋值构造函数设置为模板,此时shared_ptr具有了隐式类型转换的能力
  // 第二个模板参数的意义是,检测这种隐式转换是否合理
  // 需要注意的是 _Tp1 和 _Tp 一定是不同的类型,即标准库此外必须另提供正常的复制构造函数
  template <typename _Tp1,
            typename = typename std::enable_if<std::is_convertiable<_Tp1*,_Tp*>::value>::type> 
  shared_ptr(const shared_ptr<_Tp1>& __r) noexcept
            // 注意 __r 与本实例是不同的类型,访问数据需要 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:
  // 尽管因模板参数不同而产生不同的模板实例,彼此可以相互访问 _M_ptr
  template <typename _Tp1> friend class shared_ptr;

  _Tp*  _M_ptr;
};

以上是经过简化的 GCC 中 std::shared_ptr 的代码。之所以能够实现协变,是因为 std::shared_ptr 这两个特殊的复制构造函数和赋值操作符是模板成员函数(template member function),也就是说在构造或者赋值一个 std::shared_ptr 的时候可以接受其他不同实例化的 std::shared_ptr,这样子也就实现了隐式转换。
但是请注意,在 GCC 的实现中,赋值操作符没有复制构造函数的第二个模板参数,没有检查两个指针是否能够相互转换。我对于这一点有疑惑,目前还没有明白为什么这么设计。而在 cppreference 和 boost::shared_ptr 中,赋值构造函数和赋值操作符都没有第二个模板参数,但是 boost::shared_ptr 中强调了,对于复制构造函数,两个参数的指针必须要满足能够相互转化的条件,而对于赋值操作符,却没有这样的强调。我认为,对两个指针相互转换的检测发生在编译期,即使没有检测,到了最底层依然需要将其中的一个指针赋值给另一个指针,此时如果两个指针不兼容,则会发生编译期错误,所以没有第二个模板参数依然可行。如果大家有其他的想法,可以在评论区讨论。

到目前为止,我们讨论了对于 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

从上边的例子中,我们看似很容易得到这样的结论:几个具有协变特性的模板的复合依然是协变的。由于 Car*Vehicle* 之间的关系是协变,又由于 std::atomic 满足协变特性,则 std::atomic<Car*>std::atomic<Vehicle*> 之间的关系也是协变,进而 std::tuple<std::atomic<Car*>>std::tuple<std::atomic<Vehicle*>> 之间的关系也是协变。
那么我们看下面的例子,

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>* 之间进行赋值,又由于协变的实现是通过编写合适的复制构造函数和赋值操作符,而 std::tuple<Car>std::tuple<Vehicle> 两者之间并没有继承关系,所以会发生错误。
因此,我们在写多层包装的时候,一定要小心,对于每一个模板都要尽量了解内部实现。


逆变

我们假设 TEMPLATE<T> 满足逆变,则所有出现 TEMPLATE<Car> 都可以用 TEMPLATE<Vehicle> 代替,这样子的表达不直观,而且很容易发生运行时错误。所以逆变的应用范围十分地有限。
在介绍逆变应用之前,我们先看看 C++ 中的一个的特性:返回值协变(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* ,代替方向一致,即称为返回值协变。
假如我们把函数 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 的协变能力是通过编写代码实现的,当 std::shared_ptr<Car> 赋值给 std::shared_ptr<Vehicle> 时调用了复制构造函数或者赋值操作符,而在这里没有赋值动作(即没有函数可以调用),编译器只认定语言内置的指针和引用具有协变能力。
对于重写的函数,返回值是协变的,那参数呢?我们从重写的角度考虑,CarFactory::create 要接受 VehicleFactory::create 的实参,那么后者签名中的参数类型应该是前者签名中参数类型的子类型(或者相同的类型),

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,没有重写却被标记为 override
  // 即使没有关键字 override ,也隐藏了父类的函数 create
  Car* create(Metal*) const override { return new Car; }
};

上述写法是错误的。我们抛开 C++ 对于重写的限制,就从逻辑上来讲,VehicleFactory::create 的参数类型可以是 CarFactory::create 的子类型。如下图,

C++ 没有内置的对参数逆变的支持,那么我们应该如何实现呢?

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 是一个接受参数类型为 Car* 且什么都没有返回的函数, vehicle_sink 是一个接受参数类型为 Vehicle* 且什么都没有返回的函数;由于 vehicle_sink 可以代替 car_sink,而且 Car* 可以代替 Vehicle*,代替方向相反,因此 Sink 实现了参数逆变。
std::function 也实现了返回值协变,

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 的返回值协变和参数逆变依然成立,

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,打印结果为“ true ”

这是由于 std::function 有特殊的复制构造函数和赋值操作符。


其他

一些语言中函数的可变性

参数 返回值
C++ (since 1998), Java (since J2SE 5.0), Scala, D 不变 协变
C# 不变 不变
Sather 逆变 协变
Eiffel 协变 协变

继承与可变性

我们在讨论可变性(variance)的时候,涉及到的都是子类型(subtype),即如果 CarVehicle 的子类型,则所有出现 Vehicle 的地方都可以用 Car 代替。而在 C++ 中,实现子类型的 根源 是继承,即如果 CarVehicle 的子类(subclass),则 CarVehicle 的子类型,但是并非所有具有子类型关系的类就一定具有继承关系,例如 std::tuple<Car>std::tuple<Vehicle> 之间就没有继承关系。

引用与可变性

引用和指针都可以实现多态,但是我们经常使用的是指针而忽略引用的作用,接下来将从可变性的角度探讨引用发挥的作用。
前面我们一直讨论的是,对于两个具有子类型关系的类,他们的复杂类型之间可变性。我们讨论了利用复制构造函数和赋值操作符来实现 std::shared_ptr<Car> 代替 std::shared_ptr<Vehicle>,却没有讨论如何实现 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,调用复制构造函数, car 可以代替 vehicle

依然利用的是复制构造函数和赋值操作符。
在最后一行处,调用了 Vehicle 的复制构造函数 Vehicle(const Vehicle&) { } ,而实参是 Vehicle 的子类 Car 的实例 car,这里就用到了引用的多态性,该性质是可变性的基础,不是或有或无的。


参考

[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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259