如果类型 Car 是类型 Vehicle 的子类型(subtype,Car ≤ Vehicle,可以在任何出现 Vehicle 的地方用 Car 代替),那么关于 Car 和 Vehicle 的复杂类型(如 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 无法代替 vehicles,vehicles 也无法代替 cars,在此时表现出来的是不变。
每一次模板被实例化,编译器都会创建一个全新的类型;虽然 cars 和 vehicles 实例化了同一个模板,但是他们是两个完全不同的类型,之间没有任何关系。在 C++ 中,两个没有关系的用户自定义类型默认是无法彼此相互赋值的,但是只要我们定义合适的复制构造函数或者赋值操作符,就可以实现协变或者逆变。 std::vector 由于没有实现这样的复制构造函数和赋值操作符,因此表现出来的是不变。
不变只是其中的一种选择,其他的选择未必是错误的。事实上,对于指针和引用,C++ 选择了协变,例如 Car* 可以赋值给 Vehicle* ,更为准确地说,由于 Car ≤ Vehicle,则编译器允许在 Vehicle* 出现的地方由 Car* 来代替。
template <typename T>
using Pointer = T*;
Pointer<Vehicle> vehicle;
Pointer<Car> car;
vehicle = car; // OK, car 可以代替 vehicle,即在使用 vehicle 的时候实际上是用的是 car
上述代码中,我们利用 Pointer 表示指针,这种表示方法是为了让本文协调起来,重点讨论处理模板时的情况。这种表示方法不会造成其他副作用。
那么,当在处理模板时,我们应该在模板实例化的类之间选择怎样的关系呢?
- 首选是不变,也就是说,当 Car 和 Vehicle 的模板实例化之间是没有任何关系的。这是 C++ 的默认选择。
- 其次的选择是协变,即模板实例化之间的关系与模板参数之间的关系是一致的。例如,std::shared_ptr,std::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 中强调了,对于复制构造函数,两个参数的指针必须要满足能够相互转化的条件,而对于赋值操作符,却没有这样的强调。我认为,对两个指针相互转换的检测发生在编译期,即使没有检测,到了最底层依然需要将其中的一个指针赋值给另一个指针,此时如果两个指针不兼容,则会发生编译期错误,所以没有第二个模板参数依然可行。如果大家有其他的想法,可以在评论区讨论。
到目前为止,我们讨论了对于 Car 和 Vehicle 的一层包装(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_ptr,std::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),即如果 Car 是 Vehicle 的子类型,则所有出现 Vehicle 的地方都可以用 Car 代替。而在 C++ 中,实现子类型的 根源 是继承,即如果 Car 是 Vehicle 的子类(subclass),则 Car 是 Vehicle 的子类型,但是并非所有具有子类型关系的类就一定具有继承关系,例如 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