条条大路通罗马,各语言的泛型实现比较:CPP JAVA GO

Contents

迄今为止,如果给所有阅读过的技术类书籍排序,CSAPP在我心目中稳居前五,因为在它的视角里,复杂丰繁的技术表象里,内在的原理逻辑反而是简化统一的,这样简化统一以后,计算机技术的发展脉络就异常清晰。
语言的实现,也是类似,C++\Java\Go是目前使用的比较多的高级语言,对于同一个问题,他们各自会有什么样的处理方案,又有哪些共性和差异,这是非常有意思的视角,所以在这篇文章里,我想横向对比一下,也有助于在更广阔的视野里深化对语言机制的理解。知其然不如知其所以然。

泛型的实现方式

泛型是广泛存在于各个语言中的概念,学习一门语言,我们初期就会花相当长的时间在容器概念上。
容器是泛型使用最频繁的地方,可以有效的节省代码量,目前高级语言实现泛型的方式,主要有两种:

  • 编译时特化 或者宏展开:在编译期增加工作量,优点是运行时开销少,缺点是编译时间长,编译结果膨胀、且报错信息不明确, CPP GO RUST采用的是这种方式
  • 用装箱的方式隐藏泛型的处理细节:Java就是这种方式,Java的泛型又被称作伪泛型,强行用Object类替换所有类,并且自动给你加上类型转换的过程,好处是确实不需要在编译期折腾了,坏处是运行时效率低

这件事,总归要做,要么编译期做,要么运行时做,看你的关注点了。

除了上面两种,对于弱类型语言来说,我连类型都没有,泛型本就是天生支持!赢麻了。
这里不得不提C语言,C++和C语言究竟算是弱类型还是强类型语言,这个话题深究的话 其实是有争议的,你说他是弱类型语言,他们是有类型声明的,你说是强类型语言,有Void *这种东西,再配合强制类型转换一起用,可不就是弱类型了么,可以变成你想要的各种模样。当然,现代C++已经不太提倡这种方式了。
有时候我们讨厌弱类型,或者动态类型语言,嫌弃他们太过灵活,不便操控,但是用久了强类型的语言,我们又特别变扭,硬是在C++里造出类型推导的auto、万能类型的any,在java里使用var
足见类型这个东西,很是拧巴。

CPP

CPP的泛型实现是比较中古的方式,对新手不太友好的模板元编程。尽管它的名声不好,但是后来所谓的参数类型、类型参数,我觉得都可以看做是对模板元编程的模仿和改进。
因为最朴素,所以上手不难,但是花样太多,很难精通。嗯,很符合C++的整体气质。

上一个简单的例子:

template <typename T>
class MetaClass {
private:
    T value;
public:
    explicit MetaClass(T value) : value(value) {}

    T getValue() {
        return value;
    }
    void setValue(T v) {
        this->value = v;
    }
};

C++的泛型就是这么简单,使用起来就是:

auto intV = new MetaClass<int>{10};
std::cout << intV->getValue() << std::endl;
delete intV;
MetaClass<std::string> strV("hello");
std::cout << strV.getValue() << std::endl;

有什么忌讳和注意点么?
几乎没有,和使用一个普通的类几乎没有区别,理解这段程序,你只需要将T替换为实际传入的类型实参即可。
C++的实现也很简单粗暴,在编译的时候,检测源文件中传入的类型,针对每种类型编译一套结果。

但是,C++的模板编程不止于此,我们可以给类型形参设置默认类型、模板的形参也可以不是类型,而是一个固定常量,严格来说这些和泛型编程已经关系不大了,演化出了一种独特的编程哲学:模板元编程。核心就是利用这些规则和手段,将运行期的性能消耗转化到编译期。这里太过复杂,就不展开了。

Java泛型实现机制

类型擦除

在java虚拟机里,其实并不存在泛型这种概念,所有的泛型对象,都是普通对象。关键词:类型擦除
当我们定义一个泛型类型,编译后会成为一个原始的基本类型,就是删除了类型参数的类型名。
举个例子,我们自己编写一个泛型的容器类MyPair

public class MyPair<T> {
    private T first;
    private T second;

    public MyPair(T first, T second) {
        this.first = first;
        this.second = second;
    }

    public T getFirst() {
        return first;
    }

    public void setFirst(T first) {
        this.first = first;
    }

    public T getSecond() {
        return second;
    }

    public void setSecond(T second) {
        this.second = second;
    }
}

使用起来就像:

MyPair<Integer> myPair = new MyPair<>(1, 2);
System.out.println(myPair.getFirst());
System.out.println(myPair.getSecond());

最终编译结果里可以看到,就只有一个class文件:

file

通过类型擦除,在JVM中,MyPair类型,实际是这样的:

public class MyPair {
    private Object first;
    private Object second;

    public MyPair(Object first, Object second) {
        this.first = first;
        this.second = second;
    }

    public Object getFirst() {
        return first;
    }

    public void setFirst(Object first) {
        this.first = first;
    }

    public Object getSecond() {
        return second;
    }

    public void setSecond(Object second) {
        this.second = second;
    }
}

java里默认所有类型会继承自Object,所以这样操作也是不会有什么问题的。与C++不同,即使是在使用的过程中,会有不同的类型参数,也不会产生新的定义。
原始类型用第一个限定的类型变量来替换, 如果没有给定限定就用Object替换,对于一些比较复杂的泛型类声明,比如:

public class Interval<T extends Comparable<T> & Serializable> implements Serializable {
    private T lower;
    private T higher;

    public Interval(T a, T b) {
        if (a.compareTo(b) <= 0) {
            lower = a;
            higher = b;
        } else {
            lower = b;
            higher = a;
        }
    }
    public T getLower() {
        return lower;
    }
    public void setLower(T lower) {
        this.lower = lower;
    }
    public T getHigher() {
        return higher;
    }
    public void setHigher(T higher) {
        this.higher = higher;
    }
}

这里Interval类使用的类型参数是接口Comparable和Serializable的子类,根据上述规则,类型擦除后,其实使用的是Compareable类,但是偶尔会在必要的时候,对变量进行强制类型转化,转换为Serializable类。
如果我们把Comparable和Serializable的顺序替换一下呢?那类型擦除后的变量默认就是Serializable
这里Compareable接口限定子类必须具有方法compareTo,而Serializable其实只是一个标记类,并不具备行为,所以可以推测出,类Interval作为Compareable子类被调用的时候 更多一些,所以为了减少类型转换的消耗,把Compareable放在限定类型的第一个,是合理的。
综上,我们可以总结一个规则:撰写java的泛型程序时,为了减少运行的类型转换开销,合理规范类型限定类,最好是最大公共父类或者父接口,当类型参数是多个类和方法的子类时,把使用最多、方法较多的父类型放在第一个位置更好一些。

翻译泛型代码

根据上述规则,我们可以重新审视之前的泛型代码:

MyPair<Integer> myPair = new MyPair<>(1, 2);
Integer first = myPair.getFirst();
System.out.println(first);

其实等同于:

MyPair myPair = new MyPair(1, 2);
Integer first = (Integer)myPair.getFirst();
System.out.println(first);

泛型方法也是同理。

这就结束了么?
事情并不是那么简单,泛型类本身也是可以被继承的,比如如下的例子:

public class DateInterval extends MyPair<LocalDate> {
    public DateInterval(LocalDate first, LocalDate second) {
        super(first, second);
    }
    @Override
    public LocalDate getFirst() {
        return super.getFirst();
    }
    @Override
    public void setFirst(LocalDate first) {
        super.setFirst(first);
    }
}

如果只进行类型擦除,那么该类会变为:

public class DateInterval extends MyPair {
    public DateInterval(LocalDate first, LocalDate second) {
        super(first, second);
    }

    public LocalDate getFirst() {
        return super.getFirst();
    }
    public Object getFirst() {
        return super.getFirst();
    }

    public void setFirst(LocalDate first) {
        super.setFirst(first);
    }
    public void setFirst(Object first) {
        super.setFirst(first);
    }
}

一方面,DateInterval自己的方法被擦除了类型但是保留了参数的类型,另一方面,继承了被擦除类型的MyPair的方法,两者因为函数的签名不一致,在JVM都是有效的。
这个时候,类型擦除后的代码逻辑和类的多态机制存在了冲突,需要编译期层面对这种场景进行处理,编译器会生成相应的桥方法,对于setFirst方法,编译器生成方法setFirst(Object first)然后在这个桥方法中调用上述定义的setFirst方法。

总之,需要记住有关Java 泛型转换的事实:

  • 虚拟机中没有泛型, 只有普通的类和方法。
  • 所有的类型参数都用它们的限定类型替换。
  • 桥方法被合成来保持多态。
  • 为保持类型安全性,必要时插人强制类型转换。

约束和局限

  1. 不能用基本类型实例化类型参数

很显然,如果不是继承自Object的类型,是无法使用泛型特性的。基本类型甚至不是类,自然不能被用来实例化泛型类。

  1. 运行时类型查询只适用于原始类型

因为运行时,只看得到是Object或者限定类。

  1. 不能创建参数化类型的数组

禁止使用new Pair<String>[10]这种初始化参数化类型的数组。
由于类型擦除,这种声明和Object[]没有区别,会导致数组的类型检查功能失效。

  1. 无法在泛型类中实例化类型变量

由于类型参数会被替换成Object,那么new T()这种声明方式自然是不会达到预期目的的,作为替代方案,我们可以在泛型类中定义一个构造器表达式:

    public static <T> MyPair<T> makePair(Supplier<T> conster) {
        return new MyPair<>(conster.get(), conster.get());
    }

其中Supplier是一个函数式接口,表示一个无参数且返回类型是T的函数。

  1. 泛型类的静态上下文中类型变量无效

也是因为类型擦除

  1. 不能抛出或者捕获泛型类的实例

总的来看,由于类型擦除机制,引入了如此多的问题,本质都是同一个问题:虚拟机并不知道你传入的类型是什么。
很难说java的泛型设计能否算一个优秀的设计。

Go语言泛型机制

Go语言选择了类似C++的泛型机制,也就是真正的泛型,在编译期实实在在的编译出针对不同类型的逻辑。
在Go 1.18的版本里,正式默认开放了泛型的使用,为此也引入了一些新的概念,我们可以列举一下:

  • 类型形参 (Type parameter)
  • 类型实参(Type argument)
  • 类型形参列表( Type parameter list)
  • 类型约束(Type constraint)
  • 实例化(Instantiations)
  • 泛型类型(Generic type)
  • 泛型接收器(Generic receiver)
  • 泛型函数(Generic function)

类型形参、类型实参、类型形参列表、类型约束和泛型类型

写一个简单的go泛型的demo:

type Slice[T int | float32 | float32] []T
var intSlice Slice[int] = []int{1, 3, 5}
for _, t := range intSlice {
    println(t)
}

以上代码中,Slice是一个自定义的类型,和普通的类型定义不同,带有中括号,中括号内包含多个部分:

  • T 表示类型形参,表示当前类型的底层类型不确定
  • int|float32|float64 这部分被称为类型约束,中间的 | 的意思是告诉编译器,类型形参 T 只可以接收 int 或 float32 或 float64 这三种类型的实参,所以我们称其为 类型形参列表
  • 这里新定义的类型名称叫 Slice[T]
    我们把这种类型定义中带 类型形参 的类型,称之为 泛型类型。
    泛型类型不能直接拿来使用,必须传入类型实参(Type argument) 将其确定为具体的类型之后才可使用。而传入类型实参确定具体类型的操作被称为 实例化(Instantiations)
    上面只是个最简单的例子,实际上类型形参的数量可以远远不止一个,如下:

    type MyMap[KEY int | string, VALUE float32 | float64] map[KEY]VALUE
    var a MyMap[string, float64] = map[string]float64{
    "jack_score": 9.6,
    "bob_score":  8.4,
    }

    所有的类型定义都可以使用类型形参,所以结构体的定义也可以是:

    
    // 一个泛型类型的结构体。可用 int 或 sring 类型实例化
    type MyStruct[T int | string] struct {  
    Name string
    Data T
    }

// 一个泛型接口(关于泛型接口在后半部分会详细讲解)
type IPrintData[T int | float32 | string] interface {
Print(data T)
}

// 一个泛型通道,可用类型实参 int 或 string 实例化
type MyChan[T int | string] chan T


与其他语法糖类似,泛型的引入,不可避免的会带来一些额外的语法规则,譬如:
1. 定义泛型类型的时候,基础类型不能只有类型形参
```go
// 错误,类型形参不能单独使用
type CommonType[T int|string|float32] T
  1. 当类型约束的一些写法会被编译器误认为是表达式时会报错。建议包裹上interface{}
  2. 匿名结构体、匿名函数不支持泛型

通过使用泛型receiver定义泛型方法,我们就可以对泛型类型添加新的通用行为

func (slice Slice[T]) sum() T {
    var sum T
    for _, t := range slice {
        sum += t
    }
    return sum
}

例如上述定义,就给所有的Slice类型增加了求和统计的方法。

以上就是go泛型的基本使用。相对来说,坑点要比java少一些。

go泛型的内部原理

目前go的泛型机制就是依靠编译器生成多份代码,但是目前存在其他方向的演进趋势,值得后续进一步的观察。
具体可以参考文章https://colobu.com/2021/08/30/how-is-go-generic-implemented/


已发布

分类

, ,

来自

标签:

评论

发表回复