About

多态泛型不两全 - 面向对象中的协变与逆变

在面向对象语言中, 子类覆盖父类中的实例成员函数时, 可以酌情修改返回值类型, 使之变得更加具体. 比如在 C++ 中, 下面的代码是可行的.

Code Snippet 0-0

class base {
public:
   virtual base* copy() const
   {
       return new base;
   }
};

class inherit: public base {
   virtual inherit* copy() const
   {
       return new inherit;
   }
};

因为 inherit* 类型可以无压力转换为 base* 类型, 所以这样做并不会破坏多态机制. 这种做法称之为协变 (covariance).

然而协变一方面满足着面向对象设计的种种需求时, 另一方面与泛型的协作却非常糟糕, 比如如果尝试使用自动指针取代上述代码中的返回值类型就不正确了

Code Snippet 0-1

class base {
public:
   virtual std::unique_ptr

copy() const
   {
       return new base;
   }
};

class inherit: public base {
   virtual std::unique_ptr
copy() const
   {
       return new inherit;
   }
};

编译这一段代码会得到类似如下的错误

invalid covariant return type for 'virtual std::unique_ptr inherit::copy() const'

简而言之, std::unique_ptrstd::unique_ptr 并无实质上的继承关系, 因此无法如此重定义子类函数的返回类型.

这并不是语言层面出现的疵漏, 如果这确实可行, 反而还会引起问题, 举个例子

Code Snippet 0-2

class fruit {};
class apple: public fruit {public: void do_something_as_apple();};
class banana: public fruit {public: void do_something_as_banana();};

int main(void)
{
   std::unique_ptr a(new apple);

   /* 如果子类的自动指针对象是可以为父类的自动指针的引用直接引用
      也就是说下面这一句合法 */
   std::unique_ptr& f = a;

   /* 接下来利用 f 来重置内部的裸指针 */
   f.reset(new banana);

   /* 由于 f 就是 a 的引用, 因此 a 中的裸指针指向了
      与 apple 类型不兼容的 banana 的实例 */
   a->do_something_as_apple(); // 謎の結果
   return 0;
}

因此, 这种利用自动指针绕着弯子来造错误的行为是绝对不被允许的. 此外, 下面的这种赋值方式 (虽然明显看起来很怪异) 也是不被允许的

int main(void)
{
   inherit* i;
   base*& b = i; // 跪
   return 0;
}

之前的一篇文章也有简单介绍到, 不过当时说的是父类的二级指针不能被子类二级指针所赋值.

除了指针之外, 可以想象数组, 包括 STL 容器也会纷纷中枪, 比如 std::vector 类型的引用是无法引用 std::vector 类型的对象的.

在 Java 语言中, 为了缓解这个问题, 引入了泛型通配符. 这个名字听起来就很语死早, 又是泛又是通配, 不过程序设计世界就是这么充满纠结. 下面是 Java 中的解决方案

Code Snippet 0-3

public class Fruit {
   static class Apple extends Fruit {}

   public static void main(String[] args) {
       ArrayList apples = new ArrayList();
       apples.add(new Apple());

       ArrayList fruits = apples;

       for (Fruit f: fruits) {
           System.out.println(f.getClass());
       }
   }
}

如此惊艳的语法却解决不了一个问题: 此泛型容器的函数的参数类型不能够确定了. 即对于成员函数 fruits.add(??? fruit) 而言, 其参数类型应该是个水果倒是没错, 但是哪个子类却没法确定, 因而这个函数的精准度下降不少. 当然不仅仅是 add, 任何包含泛型类型参数的函数都将跪地不起.

更绝的是, Java 不仅设定了一个 `` 的通配方案, 还能写 ``. 比如

Code Snippet 0-4

public static void main(String[] args) {
   ArrayList fruits = new ArrayList();

   ArrayList seemsWeCanPutAppleIn = fruits;

   seemsWeCanPutAppleIn.add(new Apple());
   for (Fruit f: fruits) {
       System.out.println(f.getClass());
   }
}

这种像在马戏团骑狮子跳火圈一样的写代码方式就称之为逆变 (contravariance). 事实上在 java.util.Collections.sort 函数的一个重载中就使用到了这个技巧. 这种写法里, add 函数的泛型参数虽然不能肯定是个什么, 但必然是 Apple 的父类, 因此至少接受一个 Apple 实例是没什么问题的. 然而问题出在迭代该集合的时候, 下面这样迭代是不行的

ArrayList seemsWeCanPutAppleIn = fruits;
for (*Apple* a: seemsWeCanPutAppleIn) { // ERROR
   System.out.println(a.getClass());
}

把这个 for-each 循环拆开, 其实就是卡在 seemsWeCanPutAppleIn.get() 的返回值类型无法确定. 当然要说完全无法确定也不对, 既然是 Java 语言里, 那么这货肯定是个 Object. 所以这么干就对了

ArrayList seemsWeCanPutAppleIn = fruits;
for (Object a: seemsWeCanPutAppleIn) {
   System.out.println(a.getClass());
}

不过问题就是, 循环体里的代码可能就铺天盖地都是强制类型转换. 这样一来, Java 似乎就要尴尬地 "沦为" 一门动态类型语言了.

Tags: C++ Java 面向对象程序设计

Loading comments


. Back to Tobary book
Tobary book - Copyright (C) ZhePlus @ Bit Focus
About