泛型编程

泛型编程

在支持泛型的编程语言中,泛型常用于对不受具体类型影响的可复用的业务逻辑的实现。譬如对于排序的逻辑:

public class ArraySortViaComparable {
    public <E extends Comparable> void insertionSort(E[] a) {
        for (int i = 1; i < a.length; i = i + 1) {
            Comparable itemToInsert = a[i];
            int j = i;
            while (j != 0 && greaterThan(a[j-1], itemToInsert)) {
                a[j] = a[j-1]
                j = j - 1
            };
            a[j] = itemToInsert;
        }
    }

    private static boolean greaterThan(E left, Object right) { return left.compareTo(right) == 1; }
}

这段 Java 代码使用泛型数组作为参数实现了通用的数组排序逻辑,任意类型只要实现了 Comparable 接口,insertionSort 函数就能排序由该对象组成的数组。使用泛型能够减少重复的代码和逻辑,为工程师提供更强的表达能力从而提升效率。而 Go 1.x 中,因为其还不支持泛型,所以能找到一些如下所示的函数签名:

package sort

func Float64s(a []float64)
func Strings(a []string)
func Ints(a []int)
...

上述函数都是 sort 包提供的,它们的功能非常相似,底层的实现也使用了近乎相同的逻辑,但是由于传入类型的不同却需要对外提供多个函数。

泛型困境

泛型和其他特性一样不是只有好处,为编程语言加入泛型会遇到需要权衡的两难问题。语言的设计者需要在编程效率、编译速度和运行速度三者进行权衡和选择 5,编程语言要选择牺牲一个而保留另外两个。

我们以 C、C++ 和 Java 为例,介绍它们在设计上的不同考量:

  • C 语言是系统级的编程语言,它没有支持泛型,本身提供的抽象能力非常有限。这样做的结果是牺牲了程序员的开发效率,与 Go 语言目前的做法一样,它们都需要手动实现不同类型的相同逻辑。但是不引入泛型的好处也显而易见:降低了编译器实现的复杂度,也能保证源代码的编译速度;

  • C++ 与 C 语言的选择完全不同,它使用编译期间类型特化实现泛型,提供了非常强大的抽象能力。虽然提高了程序员的开发效率,不再需要手写同一逻辑的相似实现,但是编译器的实现变得非常复杂,泛型展开会生成的大量重复代码也会导致最终的二进制文件膨胀和编译缓慢,我们往往需要链接器来解决代码重复的问题;

  • Java 在 1.5 版本引入了泛型,它的泛型是用类型擦除实现的。Java 的泛型只是在编译期间用于检查类型的正确,为了保证与旧版本 JVM 的兼容,类型擦除会删除泛型的相关信息,导致其在运行时不可用。编译器会插入额外的类型转换指令,与 C 语言和 C++ 在运行前就已经实现或者生成代码相比,Java 类型的装箱和拆箱会降低程序的执行效率;

当我们面对是否应该支持泛型时,实际上需要考虑的问题是:我们应该牺牲工程师的开发效率、牺牲编译速度和更大的编译产物还是牺牲运行速度。泛型的引入一定会影响编译速度和运行速度,同时也会增加编译器的复杂度,所以社区在考虑泛型时也非常谨慎。Go 2 的泛型提案在面对这个问题时没有进行选择,让具体实现决定是应该影响编译速度(单独编译不同的类型参数)还是运行时间(使用方法调用在运行时决定具体执行的函数)。