Go语言基础(10)——范型基础

77 阅读5分钟

范型基础

从c++模板说起

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;
void testVector() {
    vector<int> *v = new vector<int>();
    v->push_back(3);
    v->push_back(8);
    v->push_back(1);
    v->push_back(7);
    sort(v->begin(), v->end());
    for (vector<int>::iterator i = v->begin(); i != v->end(); i++) {
        cout << *i << ",";
    }
    cout << endl;
}

c++中的vector我们一定不会陌生,其实现了可变长数组。并且vector还实现了模板,vector的定义只有一份,而我们在使用时可以使用<>指定其保存的数据的类型,上面演示的是vector,并且还调用了sort函数进行排序,最后用iterator的方式遍历了vector。查看vector的源码可知其类型定义如下

template <class _Tp, class _Allocator /* = allocator<_Tp> */>
class _LIBCPP_TEMPLATE_VIS vector

template就是c++用于定义模板的关键词,template后面跟着一个<>,它就是我们用来定义范型的。下面,我们也使用template来定义一个自己的模板数组

template <class T>
class Array {
    private:
    T *v;
    public:
    Array(int size);
    T operator[](int index) const;
    T &operator[](int index);
};

template<class T>
Array<T>::Array(int size) {
    this->v = new T[size];
}
template<class T>
T Array<T>::operator[](int index) const {
  return this->v[index];
}

template<class T>
T &Array<T>::operator[](int index) {
    return this->v[index];
}

#include <iostream>
using namespace std;

void testArray() {
    Array<int> *a = new Array<int>(10);
    (*a)[0] = 10;
    for (int i = 0; i < 10; i++) {
        cout << (*a)[i] << ",";
    }
    cout << endl;
}

上面的代码写得比较挫,虽然没有实现可变长数组,但大概表示了下template的用法。这里可以看到c++的模板跟Java的范型其实非常像,除了用模板定义类型,我们当然也可以用模板定义函数。

template <class T>
T add(T a, T b) {
    return a + b;
}

#include <iostream>
#include <string>
using namespace std;
void testAdd() {
    int i = 1, j = 2;
    cout << add<int>(i, j) << endl;
    string a = "1", b = "2";
    cout << add<string>(a, b) << endl;
}

上面的代码,由于int和string都支持了+运算符,因此add函数能正常运行且得到正确的结果。

c++的模板原理是在运行时为每种范型的变量都创建了一个新类型,这点和Java完全不一样,Java的范型原理是编译期范型参数擦除。可以比较下面两段代码

    cout << typeid(a).name() << endl;
    Array<char*> *ca = new Array<char*>(2);
    cout << typeid(ca).name() << endl;
    bool is = typeid(a) == typeid(ca);
    cout << is << endl;
    List<Integer> a = new ArrayList<Integer>();
    List<Integer> b = new ArrayList<Integer>();
    List<String> ca = new ArrayList<String>();
    System.out.println(a.getClass() == b.getClass());
    System.out.println(a.getClass() == ca.getClass());

可以发现c++的a和ca不是同一种类型,而Java的a和ca却是同一种类型,这是因为在运行时,c++的a和ca分别属array<int>和array<*char>类型,而Java的a和ca都是属于List类型。

协变、逆变和不变

还是老套路,先看一段看似没问题的代码

private static class Animal { }

private static class Cat extends Animal { }

private static class Dog extends Animal { }

public static void main(String[] args) {
    Animal a = new Cat();
    a = new Dog();

    Animal[] as = new Cat[2];
    as[0] = new Cat();
    as[1] = new Dog();
}

上面这段代码能够正常通过编译,但运行时却会在as[1] = new Dog()这一行报一个java.lang.ArrayStoreException的异常,究其原因,是as指向的是一个Cat类型的数组,虽然其引用是Animal数组类型的,能够将数组元素指向一个Dog类型的对象,但最终数组内存中却是只能储存Cat类型的对象。Java数组的这种特性叫协变。

Java范型的不变

既然数组有这种问题,咱们再来看看范型集合

private static void testList() {
    List<Animal> as = new ArrayList<>();
    as.add(new Cat());
    as.add(new Dog());
    System.out.println(as.get(0));
    System.out.println(as.get(1));
}

跟前面的Java数组一样,我们也是搞了个父类的List,一样往List往存入一个Cat和一个Dog对象,但不一样的是数组new的时候我们指定了数组的类型是Cat数组,而List我们没办法指定成ArrayList。testList函数运行起来没有任何异常,这当然,因为as这个变量最终指向的其实是一个ArrayList,前面提到过,Java的范型是用编译期的类型擦除来实现的,ArrayList当然可以同时储存Cat对象和Dog对象,如果我们将数组也换成new一个Object数组,也不会抛异常。

但为啥List的变量不能指向ArrayList的对象呢?咱就不说ArrayList是List的实现类了吧,就算把List换成ArrayList,ArrayList的变量也是不能指向ArrayList的对象的,即使Cat是Animal。Java范型的这个特点叫作不变。我们知道,在Java中,一个父类的变量是可以指向子类的对象的,由于逆否命题必真定律,那么一个对象不能被一个变量指向的话,那么这个对象的类型一定不是变量类型的子类,因此ArrayList一定不是ArrayList子类。

Java范型的协变

既然ArrayList不是ArrayList的子类,那么如果我们在某些场景下想要写成是子类呢?也就是想让一个ArrayList的变量能指向ArrayList,可以怎么操作呢?比如函数的形参定义成了ArrayList类型,而我们传的实参是ArrayList类型。当然,我们可以再new一个ArrayList类型的对象,再将ArrayList的元素一个个地加入到new出来的这个ArrayList中,但这样首先会浪费内存,再者这样写着实麻烦,有没有啥可以直接点的办法呢?答案是使用范型的协变。Java范型对协变的支持是使用在范型参数上使用extends关键词。

List<? extends Animal> ea1 = new ArrayList<Cat>();
List<? extends Animal> ea2 = new ArrayList<Dog>();
List<? extends Animal> ea3 = new ArrayList<Animal>();

但协变会产生最开始说的数组java.lang.ArrayStoreException的异常问题,因此Java范型的协变在编译期要求是只读的,也就是a变量只能get不能add,因此get出来的是一个Animal类型的变量,Animal类型的变量指向get出来的Cat对象一定是安全的,而如果add的话,我们可以add一个Dog类型的对象,而cs实际上只能储存Cat类型的对象,因此是不安全的。

Java范型的逆变

协变是为了让范型集合实现继承的效果,因此使用了extends关键词,并且协变后将变得只读不可写。那么如果我们希望集合可写呢?既然可写,那么就必须保证写入的类型必须是声明范型或其子类才能保证安全,这个要求其实跟范型的协变一样。

List<? super Cat> addList1;
addList1 = new ArrayList<Animal>();
addList1 = new ArrayList<Cat>();

addList1.add(new Cat());

Java范型使用关键词super来定义一个可写的范型,这是的? super Cate表示addList1只能指向一个Cat或其父类作为实际类型的范型集合对象。由于集合对象被要求是Cat的父类了,而在add时,又只能add一个Cat或其子类的对象,因此这种写入是绝对安全的。

Java范型的上界与下界

从前面范型的协变与逆变,可以看到协变的变量只能指向其子类的对象;而逆变则相反,逆变的的变量只能指向其父类的对象,因此可以将协变看成范型的上界,逆变看成是范型的下界。如果在定义一个范型变量时同时规定其上界X与下界Y,则可安全地决定这个范型变量指向的对象必须满足是X的子类和是Y的父类。

List<Animal> al = new ArrayList<>();
List<Cat> cl = new ArrayList<>();
List<RedCat> rcl = new ArrayList<>();
List<BigRedCat> brcl = new ArrayList<>();


List<? extends Cat> list1;
List<? super RedCat> list2;
// 0
list1 = al; // compile error
list2 = al;
// 1
list1 = cl;
list2 = cl;
// 2
list1 = rcl;
list2 = rcl;
// 3
list1 = brcl;
list2 = brcl; // compile error

上面的代码我们再创建RedCat为Cat的子类,而BigRedCat是RedCat的子类。我们定义了上界为Cat和下界为RedCat,这就要求指向的对象只能是Cat或者RedCat。下面列了4种情况,分别是Cat的父类Animal、Cat、RedCat以及RedCat的子类BigRedCat。可以看到第一种情况在上界拦截了异常,因为定义了上界是Cat,指向了Animal会报错;第四种情况在下界拦截了异常,因为定义了下界是RedCat,指向了BigRedCat也会报错。