今年3月随着Go 1.18版发布,引入了一个重大的语言特性:泛型编程。这个特性在发布前引起了一定的争议。崇尚少即是多的一边认为这个特性不是很必要,应该谨慎引入。另一边则认为这是语言必不可少的特性。最后Go的泛型还是如期而至

泛型编程在其他编程语言也遇到了不同的问题,比如在Java 1.0时是没有引入泛型编程支持,到了Java 1.5的时候引入了泛型,但由于引入时间过晚,有大量的标准库和第三方库无法支持,Java选择了妥协采用间接的类型擦除方式来实现泛型编程,导致泛型的使用复杂度增加,同时场景受限。而C++比较明智,一开始就支持泛型,所以在C++ STL标准库里面大量的算法都是采用泛型实现,整个语言体系中泛型占据了核心位置

学习一个语言的特性可以参考其他语言同样的特性,这样就可以了解这个语言特性实现是否设计合理,是否优雅,存在哪些局限性

语言版本:
C++11
Java 11
Go 1.18

1. 泛型函数

泛型函数用来支持当一个算法用在多种数据类型上,为了避免重复的定义函数,而用泛型函数来支持

两个数据进行取小操作

//C++
template <class T>
T min(T a, T b){
    return a < b ? a : b;
}

//Java不支持

//Go
func Min[T int|float64](a, b T) T {
  if a < b {
    return a
  } else {
    return b
  }
}

2. 泛型函数-显示特化

当泛型函数用来支持一个算法用在多种数据类型上,但在某个类型数据上泛型函数的实现不适合该类型,那么我们可以显示特化该类型的实现算法

/C++独有
template<> string min<string>(string a, string b){
    return a.size() < b.size() ? a : b;
}

3.泛型类

泛型类的目的是创建一个集合类型,里面的各种数据类型可以进行相同的操作,泛型类避免了多个数据类型的重复定义

//C++
template<class T>
class Queue{
public:
  Queue();
  ~Queue();
  T& remove();
  void add( const T&);
  bool isEmpty();
}

//Java
public class Queue<T> {
    public Queue(){
    }
    public T remove(){
        //...
    }
    public void add(final T a){
        //...
    }
}

//Go
type Queue[T interface{}] struct{
    element []T
}
func (q *Queue[T]) remove() *T{
    //...
func (q *Queue[T]) add(a *T){
    //...
}

类型擦除

前面说到Java是在1.5版本时才引入泛型支持,这个时候已经有大量的工程,类库基于非泛型代码,这个时候引入泛型就会相当复杂,因为不能导致历史代码不兼容。

这时Java做出了妥协,采用类型擦除的方式实现了泛型,并保持历史代码的兼容性。采用这种方式有一个明显的缺陷是在泛型代码内部,无法获取有关任何泛型参数的信息

当在代码尝试调用泛型特有的信息时,Java的实现就会变得复杂

//C++
template<class T> 
class Manipulator {
    T obj;
public:
    Manipulator(T x) { obj = x; }
    void manipulate() { obj.f(); }//compiler check
};

class HasF {
public:
    void f() { cout << "HasF::f()" << endl; }
};

上面代码调用泛型T的f()方法,在C++的实现中编译器会检查泛型T是否含有f()方法,如果有则编译通过。

在Java中由于类型被擦除,在编译时刻,编译器无法检查是否含有f()方法,这个调用会编译报错。


class Manipulator<T> {
    private T obj;
    
    Manipulator(T x) {
        obj = x;
    }
    
    // Error: cannot find symbol: method f():
    public void manipulate() {
        obj.f();
    }
}

为了解决这个问题Java在泛型支持中引入新关键字extends,表示该泛型T是后续类型或其子类

public class Manipulator2<T extends HasF> {
    private T obj;

    Manipulator2(T x) {
        obj = x;
    }

    public void manipulate() {
        obj.f();
    }
}

这样引入复杂度才解决这个问题。

为了缓解类型擦除带来的问题,Java同时还引入通配符?, 逆变super等关键字支持泛型,可见开始语言特性设计考虑不慎,后续就会引入大量不必要的复杂性

在Go的泛型实现中,就算指定了类型限制,编译器也无法检查调用类型,这方面Go的泛型限制更大,下面代码编译不过

type Manipulator[T HasF] struct{
    obj T
}
func (m Manipulator[T]) manipulate(){
    m.obj.f() //compile error: undefined
}
type HasF struct{
}
func (h HasF) f(){
}

泛型类特化与偏特化

C++同时还支持泛型类的特化与偏特化能力,这些都是Java与Go泛型不支持的特性,这里就不举例了

4.泛型方法

当类中少数方法需要泛化,并不需要全面泛化类时,泛型方法就登场了

//C++
class Queue{
public:
   template<class Iter>
      void assign(Iter first, Iter last){
      //...
      }
}

//Java
public class Queue {
   public <Iter> void assign(Iter first, Iter last){
       //...
   }
}

//Go不支持泛型方法

5.泛型接口

当设计接口支持多种数据类型时,就会用到泛型接口

//C++
template class<T>
class MinMax{
public:
  virtual T max()=0;
};

//Java
interface MinMax<T extends Comparable<T>> {
  T max();
}
class MyClass<T extends Comparable<T>> implements MinMax<T> {
  public T max() {
    //...
  }
}

//Go
type MinMax[T any] interface{
  max() T
}

这篇文章介绍了主要的泛型场景,我们看到三种语言泛型能力C++>Java>Go,C++从语言开始设计就考虑泛型没有历史包袱,所以泛型能力最强,表达力最优秀。Java借鉴了C++语言,但在泛型设计上慢了一步后期加入泛型只有采用妥协方式(类型擦除)来实现,在泛型支持场景上受限,同时复杂度增加。Go语言受到原作者的影响,一开始并不想支持泛型,在后期社区反馈中才考虑加入泛型,也作出妥协,增加了复杂度,场景也受限更多。