静态类型语言中类型的灵活性——泛型和变型

编程入门 行业动态 更新时间:2024-10-07 10:24:49

静态<a href=https://www.elefans.com/category/jswz/34/1771355.html style=类型语言中类型的灵活性——泛型和变型"/>

静态类型语言中类型的灵活性——泛型和变型

《静态类型语言中类型的灵活性——泛型和变型》源站链接,阅读体验更佳!
协变与逆变(Convariance and contravariance)是在计算机科学中,描述具有父/子型别关系的多个数据类型通过型别构造器构造出的多个复杂数据类型之间是否有父/子型别关系的用语。

许多程序设计语言(尤其是支持面向对象编程范式的编程语言中)的类型系统支持子类型。例如Cat是Animal的子类型,那么值为Cat类型的表达式可以用于任何出现Animal类型表达式的地方。所谓的变型,是指如何根据组成类型之间的子类型关系,来确定更复杂类型(比如Cat集合和Animal集合、返回Cat的函数和返回Animal的函数等等)之间的子类型关系。当我们基于现有的类型构造出更加复杂的类型,原本类型的子类型性质可能被保留、翻转或者是被忽略,这取决于类型构造器1的变型性质。

在一门程序设计语言中的类型系统中,一个类型规格或者类型构造器可能是:

  • 协变(convariant),如果它保持了子类型关系;
  • 逆变(contravariant),如果它逆转了子类型关系;
  • 不变(invariant),新类型之间不具备子类型关系。

编程语言的设计者在定制数组、继承、泛型数据类型、一等函数等类型规则的时候,必须将“变型”列入考量。将类型构造器设计成协变、逆变而非不变,可以让类型系统的灵活性大大提升,但是也会造成类型安全方面的问题。另一方面,程序员经常觉得逆变是不直观的;如果为了避免运行时的错误而精确追踪变型,可能会导致非常复杂的类型规则。为了保持类型系统简单而同时提升类型系统的灵活性,一门编程语言可能会把型别构造器设计成协变的,即使这样可能会违反类型安全。

下面,我们先从最常见的数组型别构造器来展开我们对变型的讨论。

数组

首先考虑数组类型构造器:从Animal类型,可以得到Animal[]。是否可以把它当做:

  • 协变:一个Cat[]也是一个Animal[]
  • 逆变:一个Animal[]也是一个Cat[]
  • 不变:Cat[]和Animal[]之间没有子类型关系

如果要避免类型错误,且数组同时支持读、写操作,那么只有不变是安全的。

如果是协变的,那么Cat[]也是一个Animal[],这个时候我们可以把一个Cat[]类型的变量赋值给一个Animal[]类型的变量:Animal[] animals = Cat[] cats;

这个时候我们想要从anmials中读取值是安全的,因为我们从animals中读取值的时候认为我们读到的值就是一个Animal,而Cat显然是一个Animal。

但是这个时候我们向animals中写入值可能就会造成类型污染,因为Dog也是一个Animal,我们就可以把Dog写入到animals里面,而animals的实际类型其实是Cat[],这个时候一群Cat里面却混入了一只Dog,如果我们再从cats里面读取值,可能就会出现问题了,因为我们认为从cats里面读取到的值永远是一只Cat,而这个时候可能会得到一只Dog,显然我们不能认为Dog是Cat。所以,协变只能保证读操作的安全性

如果是逆变的,那么Animal[]也是一个Cat[],这个时候我们就可以把一个Animal[]类型的值赋值给一个Cat[]类型的变量:Cat[] cats = Animal[] animals;

这个时候我们向cats中写入值是可以的,因为我们只能向cats中写入Cat类型的值,而Cat也是一个Animal,这个时候显然是没有问题的。

但是,我们想从cats中读取值却是不安全的,因为我们可以向animals中写入Cat,也可以写入Dog,因为在一群Animal中既有Cat也有Dog是非常合理的,所以这个时候我们从一群Animal中获取一个Animal之后,这个Animal却不一定是Cat。所以,逆变只能保证写操作的类型安全性

这示例了一般现像。只读数据类型(源)是协变的;只写数据类型(汇/sink)是逆变的。可读可写类型应是“不变”的。

Java与C#中的协变数组

早期版本的Java与C#不包含泛型,在这样的设置下,使数组为“不变”将导致许多有用的多态程序被排除。

例如,考虑一个用于重排数组的函数,或者测试两个数组相等的函数,函数的实现并不依赖于数组元素的确切类型,因此可以写一个单独的实现而适用于所有的数组:

boolean equalArrays (Object[] a1, Object[] a2); // 判断数组是否相等
void shuffleArray(Object[] a); // 重排数组

然而,如果数组类型被处理为“不变”,那么它仅能用于确切为Object[]类型的数组。对于字符串数组等就不能做重排操作了。

所以,Java与C#把数组类型处理为协变。在C#中,string[]object[]的子类型,在Java中,String[]Object[]的子类型。

如前文所述,协变数组在写入数组的操作时会出问题。Java与C#为此把每个数组对象在创建时附标一个类型。 每当向数组存入一个值,编译器插入一段代码来检查该值的运行时类型是否等于数组的运行时类型。如果不匹配,Java中会抛出一个ArrayStoreException(在C#中是ArrayTypeMismatchException):

// a 是单元素的 String 数组
String[] a = new String[1];// b 是 Object 的数组
Object[] b = a;// 向 b 中赋一个整数。如果 b 确实是 Object 的数组,这是可能的;然而它其实是个 String 的数组,因此会发生 java.lang.ArrayStoreException
b[0] = 1;

在上例中,可以从b中安全地读。仅在写入数组时可能会遇到麻烦。

这个方法的缺点是留下了运行时错误的可能,而一个更严格的类型系统本可以在编译时识别出该错误。这个方法还有损性能,因为在运行时要执行额外的类型检查。

Java与C#有了泛型后,有了类型安全的编写这种多态函数。数组比较与重排可以给定参数类型

<T> boolean equalArrays (T[] a1, T[] a2);
<T> void shuffleArray(T[] a);

泛型(参数化类型)

上文中我们介绍数组的时候也已经引出了泛型,我们在《高级语言基本世界观——类型系统》一文中也介绍了在具有静态类型的编程语言中灵活使用类型的最常见的手段就是使用泛型。泛型允许我们在编写基于类型的代码的时候使用一些以后才会指定具体类型的类型参数,在实例化的时候才为类型参数指定具体的类型。

各种程序设计语言和其编译器、运行环境对泛型的支持均不一样。Java、C#、Swift 和 .NET 中称之为泛型(generics);Scala 和 Haskell 称之为参数多态(parametric polymorphism);C++称之为模板。具有广泛影响的1994年版的《Design Patterns》一书称之为参数化类型(parameterized type)。

本人也更加倾向于称之为参数化类型,使用了泛型之后,我们声明类型的时候就可以声明类型参数,这样泛型类型就变得类似于一个“函数”,只不过这个函数一般是在语言的编译阶段起作用。

那么,参数化类型大体的运作方式是怎样的呢?我们以Java为例进行一个简单的介绍:

class MyList<T> {}
class MyStringList extends MyList<String> {}
public class MainClass {public static void main(String[] args) {MyList<String> stringList = new MyStringList<>();MyList<Animal> animalList = new MyList<>();}
}

上面代码第一行中我们声明了一个泛型类,它具有一个类型参数,那么在MyList的使用上下文中,我们就必须为其传入一个类型实参,比如上面代码的第2、5、6行。

当一门语言加入了泛型之后,那么对于类型参数的变型性质就是不得不考虑的问题。对于一个类型,它的上下文其实只有两类,那就是声明和使用,因此对于变型性质的考虑也就必须从这两个方面入手。

声明点变型

声明点变型指的是在声明泛型类型的时候,指定该类型对类型参数的变型性。支持声明点变型的比较流行的语言有C#、Kotlin、Scala等,其中C#只支持接口的声明点变型,而Kotlin和Scala既允许接口声明点变型也支持具体数据类型的声明点变型。本人比较喜欢Kotlin语言,所以这里以Kotlin为例进行说明。

在Kotlin中,每个类型参数都可以被标注为协变(out)、逆变(in)或者不变(不加任何标注)。使用关键字outin其实是Kotlin语言借鉴了C#,但是个人感觉这是非常直观的,因为我们在上文中提到,协变对于读操作来讲是安全的,逆变对于写操作来说是安全的,而out和in这两个方向分别对应了数据的读和写。

在我们标注了类型参数的变型性之后,类型检查器就应该检查我们对类型变量的使用是否符合类型安全规则。也就是说,一个被声明为协变的类型变量,不能出现在逆变或者不可变的位置上;一个被声明为逆变的类型变量,不能出现在协变或者不可变的位置上,如下Kotlin代码:

package site.laomst.blog.controllerclass Test<in T> {var bt: T? = nullfun setT(v: T) {}fun getT(): T? {return null}
}

上面的Kotlin代码其实是不能通过编译的,原因是出现在第四行的bt:T应该是一个不可变的位置,而出现在第六行的T应该是一个协变的位置,但是类型变量T的变型性被声明为了逆变。

设计一个让编译器能在所有类型参数上自动推断出尽量好的变型的类型系统是可能的。然而,分析过程可能由于许多原因而变得复杂:其一,分析过程不是局部的,因为一个接口的变型性质取决于其所有使用到的接口;其二,为了得到最优解,类型系统必须允许双向变型——既是协变、同时也是逆变——的类型参数;其三,类型参数的变型性质应当是接口设计者深思熟虑的结果,而不是随机发生的事情。因此,大部分语言都几乎对变型不做干预。

使用点变型(通配符)

在声明类型参数的时候就指定类型参数的变型性是非常方便的,因为这些变型性会自动应用到所有的类型使用上下文中。

但是,声明点标记法的一个缺点就是许多接口的类型必须是不变的。例如Java中的List(其实Java是不支持声明点变型的),这里的List是同时支持读写操作的,为了类型安全,这里的T的变型性只能是不变。但是,有的时候我们可能想要提供一个操作受限的视图比如只读列表。这个时候API的设计者可以提供一个附加的接口用来提供一个对T协变List类型(如果语言支持声明点变型的话),或者提供一个单独的只有读方法的接口类型。

但是,这样的做法就显得比较笨拙,同时代码的重用性也收到了限制。

使用点标记法试图给某个类的用户以更多的机会去继承,而不要求该类的设计者分开定义具有不同变型性质的若干接口。当某个类或者接口被应用于声明变量的时候,程序员可以声明用到的只有成员函数的一个子集(该类的一个操作受限的视图),这个时候,类的设计者就不再需要把变型纳入考虑,从而提高了代码的可重用性。

Java中的泛型是不支持声明点变型的,它只支持使用点变型,这在Java中称为通配符,这里我们以一段Java代码来说明一下使用点变型:

public class Tester2 {static class Animal {}static class Cat extends Animal {}static class Dog extends Animal {}public static void main(String[] args) {// 准备数据List<Cat> cats = new ArrayList<>();cats.add(new Cat());List<Animal> animals = new ArrayList<>();animals.add(new Cat());animals.add(new Dog());// 使用点协变List<? extends Animal> animalList = cats;animalList.add(new Cat()); // ErroranimalList.add(new Dog()); // ErrorAnimal animal = animals.get(0);// 使用点逆变List<? super Cat> catList = animals;catList.add(new Cat());Cat cat = catList.get(0); // ErrorObject obj = catList.get(0);}
}

在第15行代码中,我们声明了一个List<? extends Animal>类型的变量animalList,这里,编译器会认为 animalList的编译时类型是 List,但是animalList变量对于其类型参数是协变的,所以animalList的运行时类型可能是List、List或者是List,对于协变,读是安全的,所以第18行代码可以通过编译,但是对于协变,写入操作就不再是类型安全的,所以第16、17行代码都不能通过编译。

在第21行代码中,我们声明了一个List<? super Cat>类型的变量 catList,这里,编译器会认为 catList的编译时类型是 List,但是catList变量对于其类型参数是逆变的,所以catList的运行时类型可能是 List或者是List,而逆变对于写入操作是安全的,所以第22行代码可以通过编译,但是逆变对于读操作就不是类型安全的了,所以第23行代码无法通过编译。但是Object是Java中所有引用类型的基类,所以我们任何时候都可以从一个List中读取一个Object,所以第24行代码又是可以通过编译的。

通过上面的例子我们可以看出,声明点变型允许我们在设计类的时候不考虑类型变量的变型性,而在使用的时候声明变型性。

其实本人并不是特别喜欢Java中为了精简关键字而在泛型中重用extends和super关键字的做法,因为这对于代码的可读性来说是一种伤害,我们可以对比一下和上面Java代码等价的Kotlin代码:

open class Animal
class Cat: Animal()
class Dog: Animal()fun main() {// 准备数据val cats: MutableList<Cat> = ArrayList()cats.add(Cat())val animals: MutableList<Animal> = ArrayList()animals.add(Cat())animals.add(Dog())// 声明点协变val animalList:MutableList<out Animal> = cats;animalList.add(Cat()) // ErroranimalList.add(Dog()) // Errorval get:Animal = animalList.get(0)// 使用点逆变val catList:MutableList<in Cat> = animalscatList.add(Cat())val cat:Cat = catList.get(0) // Errorval obj:Any = catList.get(0) // Error
}

所不同的是,Kotlin的类型检查更加严格一些,只要是从逆变中进行读操作,就是不可以通过编译的,比如上面的第23行。

在Kotlin中使用out和in关键字显然代码的可读性是更好的,自文档化程度更高。

使用点变型中必须指定界

在声明一个对类型参数协变的变量的时候,我们需要为类型变量指定协变的上界;在声明一个对类型参数逆变的变量的时候,我们需要为类型变量指定下界。这是因为,对于静态类型的语言来说,一个变量必须具有一个明确且唯一的编译时类型。

在上文的例子中我们也提到了,对于List<? extends Animal>类型的变量animalList,编译器会认为他的编译时类型是List,对于List<? super Cat>类型的变量 catList,编译器会认为它的编译时类型是List。

同时,必须指定界的另一个原因是,在我们使用泛型的时候,必须为类型形参指定具体的类型实参。

使用点变型提供了额外的灵活性,允许更多程序得以通过类型检查。然而,它们因为给语言带来的复杂性、以及所引发的复杂类型签名和错误消息而饱受批评。

星形投影

星形投影可以用于我们不知道关于类型实参的任何信息的地方,例如在Java中,一个表示包含未知元素类型的列表可以声明为List<?>,这在Java中其实等价于List<? extends Object>,因为在Java中Object是所有引用类型的基类。

同时星形投影对于不关心类型实参的确切值的地方非常有用,比如Java中的Class,我们往往不会关系其类型实参的具体值。

类型参数约束

就像Java、Kotlin这样的语言中,对于类型的能力检查其实是基于类型本身的,思考如下的场景,我们在声明一个类型变量的时候,需要类型中具有run()和jump()这两个方法,如下Java代码:

public class Tester2 {public static <T> void train(T t) {t.run(); // Errort.jump(); // Error}
}

上面的第4、5行代码是不能通过编译的,因为Java编译器不知道T的具体类型是什么,所以它不知道里面是否具有run()和jump()方法,但是我们对上面的代码稍加改造之后:

public class Tester2 {interface Runnable {void run();}interface Jumpable {void jump();}public static <T extends Runnable & Jumpable> void train(T t) {t.run();t.jump();}
}

现在,上面的代码可以通过编译了,因为我们在声明类型变量T的时候为其指定了两个上界,所以不管T的实际值是什么,它都必须是Runnable和Jumpable的子类型,所以它就具有了run和jump的能力。

通过对类型形参指定上界约束,我们可以把类型实参限定在某一个(多个)类型族内,从而可以使用这些类型族中具有的能力。

C++模板的能力检查类似于鸭子类型

在C++模板中声明的类型变量,我们可以假设它有任何能力,因为C++模板其实是一种预处理机制,在进行实际的替换之后,还会重新走编译的流程,而在编译的时候编译器会检查具体类型中是不是能够满足我们代码的编译要求。我们拿《高级语言基本世界观——类型系统》一文中的C++的例子来进行简单的说明:

#include <iostream>using namespace std;template <typename T>
void duck_like_tweet(T *duck_like) {
duck_like->tweet();
}class Goose {
public:
void tweet();
};void Goose::tweet() {
cout << "Goose" << endl;
}class Duck {
public:
void tweet();
};class Human{
public:
void tweet(string &msg);
};void Human::tweet(string &msg) {
cout << msg << endl;
}void Duck::tweet() {
cout << "Duck" << endl;
}int main() {
duck_like_tweet(new Goose);
duck_like_tweet(new Duck);
//  duck_like_tweet(new Human);
}

void duck_like_tweet(T *duck_like)这个模板方法中,我们并不知道T的实际类型是什么,但是我们要求这个类型的值能够鸣叫。c++的编译器在根据实际的类型生成对应类型的代码之后,会再走一遍编译的流程,这时候就会判断传进来的类型是不是符合要求,如果我们把上面c++代码的最后一行的注释打开,编译器就会提示如下错误:

意思就是Human类中并没有符合使用要求的tweet()方法。这和鸭子类型的思维是差不多的,只不过能力检测是在静态阶段由编译器完成的。

函数类型

在把函数作为一等公民的语言中具有函数类型的变量,比如“一个函数期望输入一只Cat并返回一只Animal”,我们这里把这个函数记为Cat -> Animal

在这样的语言中需要指明什么时候一个函数是另一个函数的子类型,换句话说,就是在一个期望某个函数类型的上下文中,什么时候可以安全地使用另一个函数类型。

可以说,如果函数f比函数g接受更一般的参数类型,而且比函数g返回更特化的结果类型,这个时候函数f可以安全地替换函数g。

例如。函数类型Cat -> Cat可以安全地用于期望Cat -> Animal的地方,因为Cat是Animal的子类型,所以期望接收Animal的函数接收Cat也是安全的;类似的,函数类型Animal -> Animal可以用于期望Cat -> Animal的地方,因为Cat是Animal的子类型,所以返回Animal的函数返回Cat也是安全的。

如果我们把函数记为类型构造符 A -> R,那么它对于返回类型R是协变的,对于输入类型A则是逆变的。

在处理高阶函数时,这一规则可以应用多次。例如,可以应用这一规则两次,得到(A’→B)→B ≦ (A→B)→B 当 A’≦A。即,类型(A→B)→B在A位置是协变的。在跟踪判断为何某一类型特化不是类型安全的可能令人困扰,但是比较容易计算哪个位置是协变或逆变:一个位置是协变当且仅当在偶数个箭头的左边。

面向对象语言中的继承

在面向对象的编程语言中,往往允许子类重写父类的方法,如果语言具有静态类型系统,那么当我们在子类中重新父类的方法的时候,编译器就必须检查重写的方法是否具有正确的类型。虽然一些语言中要求子类中方法的签名必须和父类中方法的签名保持一致,但是允许重写方法有一个更特化的类型也是类型安全的。对于大部分的方法子类化的规则来说,要求返回值的类型更具体,也就是协变,而接受参数的类型更宽泛,也就是逆变。这也是符合里氏替换原则的。

下面我们以Java语言的语法为例来进行一个简单的介绍:

public class AnimalShelter {Animal getAnimal() {}void putAnimal(Animal animal) {}
}

返回值协变

在支持返回值协变的语言中,AnimalShelter的子类型可以重写getAnimal方法来返回一个Animal的子类型:

public class CatShelter extends AnimalShelter {@OverrideCat getAnimal() {}
}

主流的静态类型的面向对象编程语言中,支持返回值协变的语言是比较多的,比如Java、C++、Scala等,但是C#是不支持的。

输入参数的逆变

类似的,子类重写方法接受更宽泛的输入参数也是类型安全的:

public class CatShelter extends AnimalShelter {void putAnimal(Cat cat) {}
}

然而,允许输入参数逆变的静态类型的编程语言并不多,比如Java和C++中,会把上面的putAnimal(Cat)函数认为是putAnimal(Animal)函数的一个重载。

这是因为在Java和C++中不仅支持子类型重写父类的方法,同时支持方法的重载,而对于重载方法的解析是发生在语言的编译期的,我们称之为静态绑定,而对于重写方法的解析则需要借助虚方法表在语言的运行时进行动态绑定。

现实世界对输入参数协变的依赖

AnimalShelter是动物收容所,它可以接收包括Cat和Dog在内的种种的Animal,但是对于CatShelter来说,它可能就是专门用于接收Cat的,那么这个时候它的putAnimal方法接收Cat类型的参数也是合情合理的,这就造成了子类重写父类方法的时候输入参数的协变,这显然不是类型安全的。

但是也有一些语言允许在子类重写父类方法的时候指定更特化的输入类型,即参数的协变,比如Eiffel。

另一个需要参数类型协变的例子是所谓的二元方法,即方法的参数类型和方法所在的对象具有相同的类型的情况。例如compareTo方法:apareTo(b)检查a和b在某种排序下的先后关系,我们这里不考虑对不同类型对象的比较,只考虑相同对象之间的比较,还是以Java为例,在就一点的Java版本中,比较方法是以接口Comparable的方式指定的:

public interface Comparable {int compareTo(Object o);
}

这种方式的缺点是方法参数的类型被固化为了Object。比较典型的做法是对输入的参数进行向下类型转换,如果转换失败,就报错:

public class Animal implements Comparable {@Overrideint compareTo(Object o) {Animal other = (Animal)o;...}
}

但是如果方法的参数是支持协变的,那么在特定的子类型中,compareTo方法的参数就可以直接指定为特定的类型,从而把类型转换消除。(当然,在运行时发生类型错误的时候还是会报错的)。

去除对输入参数协变的依赖

其他的语言特性可能用来弥补输入参数类型协变的缺失。

在有泛型(参数化类型)并且支持泛型投影(受限量词)的编程语言中,我们前面AnimalShelter和CatShelter的例子就可以用更加类型安全的方式进行重写,这里我们不再定义AnimalShelter,而是定义一个参数化的类型Animal,并且对T施加一个上界(关于Java中泛型的使用,会在Java相关的文章中进行介绍):

public Shelter<T extends Animal> {T getAnimal() {}void setAnimal(T animal) {}
}

这个时候,我们在继承Shelter的时候就需要为其传入一个类型引元,并且因为我们对T施加了Animal上界,所以我们为Shelter这个类型构造器传入的类型必须是Animal的子类型,比如Cat,这个时候Shelter中所有使用了类型参数T的地方会被编译器自动替换为Cat:

class CatShelter extends Shelter<Cat> {Cat getAnimal() {}void setAnimal(Cat animal) {}
}class DogShelter extends Shelter<Dog> {Dog getAnimal() {}void setAnimal(Dog animal) {}
}

并且,这样写之后编译器还可以针对这些信息进行额外的类型检查:

Shelter<Cat> catShelter = new CatShelter(); // 合法的
Shelter<dog> dogShelter = new DogShelter(); // 不合法

相似的,在新版本的Java中,Comparable接口也被参数化了,从而允许以一种类型安全的方式省去向下类型转化:

public interface Comparable<T> {int compareTo(T o);
}

另一个可以弥补子类重写父类方法输入参数协变缺失的语言特性是多分派2,二元方法难写的一个原因就是在类似于对apareTo(b)这样的方法调用中,对compareTo方法的选择其实应该同时依赖于a的类型和b的类型,举个例子:

class A {void foo(Object o){}
}class B extends A {@Overridevoid foo(Object o) {}void foo(String str) {}
}

上面的代码是Java的,其中B是A的子类,B重写了A中的foo(Object)方法,并且提供了foo(String)这样一个重载方法,现在我们假设Java是支持多分派的,那么如下代码:

A a = new B();
a.foo("123");

上面代码的第二行应该是对B类中foo(String)方法的调用,即主调a的方法调用的是其运行时类型B中的方法,同时,会根据参数的运行时类型路由到方法foo(String)

但是,在经典的静态类型的面向对象语言中,其实只有a的实际运行类型会被分派,而参数则不会,所以在上面的例子中,a.foo("123")这行代码实际调用的其实是B类中的foo(Object)方法。因为方法选择的规则是在可用方法中选择特化程度最高的,如果一个方法重写了另一个方法那么,主调参数会选择最特化的子类型。而另一方面,为了保证类型安全,语言又得要求剩下的参数越泛化越好。而多分派不止会考虑主调参数的实际类型,其余参数的实际类型也会考虑选择最特化的,这其实就是一种输入参数的协变。

然而不幸的是,大多数的静态类型面向对象编程语言是不支持多分派的。比如上面的Java代码,Java编译器会认为B类中的foo(String)方法是foo(Object)方法的一个重载,而a的编译时类型是类型A,类型A中没有foo(String)方法,所以在重载解析的时候就只能路由到foo(Object)方法为止,而实例方法的运行时动态绑定(多态)则把实际调用的方法路由到了B类型的foo(Object)方法中,这就是一种典型的单分派。

后面我们介绍面向对象设计模式的时候会提到一个访问者设计模式,这个设计模式其实就是在单分派语言中实现多分配的一种实践。

函数的返回值协变,输入参数逆变的含义

注意,我们上文中反复提到的反返回值协变,输入参数逆变是两个函数类型可以兼容的要求,主要的场景有一等函数和面向对象语言的继承中子类型重写父类型中方法

而对于出现在返回值和参数中的泛型类型,它们对于类型参数是逆变的还是协变的主要还是由我们的函数内部想要如何操作数据来决定的,如下Kotlin代码:

fun <T> copyData(source: MutableList<T>, destination: MutableList<in T>) {for (item in source) {destination.add(item)}
}

在上面的copyData函数中,我们显然是想往destination中写入数据,所以destination必须是可写的,那么destination对于其类型参数就必须是逆变的。

这篇文章中,我们简单介绍了一下泛型和变型,对于灵活使用类型而言,理解泛型和变型是必须要跨过去的一个坎。

文中花费和很大的篇幅来介绍函数类型的型变,函数对于任何一门高级程序设计语言而言都是一个非常重要的抽象,而且,现在函数式编程范式也越来越流行,本人也非常喜欢函数式编程范式,而想要拥抱函数式编程,那么了解高级语言中对函数的抽象就是比不可少的,下一篇文章,我们将介绍一下不同的语言中函数的抽象能力。

感谢你耐心读完。本人深知技术水平和表达能力有限,如果文中有什么地方不合理或者你有其他不同的思考和看法,欢迎随时和我进行讨论(laomst@163)。


  1. 类型构造器也称为类型构造子,是把若干已知的类型构造成新类型的手段。例如数组T[]是若干相同类型T元素的有序集合,我们说从T类型构造出“T的数组”这一类型构造器是[]。另外比较常见的类型构造器还有函数、以及泛型等等。 ↩︎

  2. 多分派或译多重派发(multiple dispatch)或多方法(multimethod),是某些编程语言的一个特性,其中的函数或者方法,可以在运行时间(动态的)基于它的实际参数的类型,或在更一般的情况下于此之外的其他特性,动态分派。这是对单分派多态的推广,那里的函数或方法调用,基于在其上调用方法的对象的派生类型,而动态分派。多分派使用一个或多个实际参数的组合特征,路由动态分派至实现函数或方法。所谓 Single Dispatch,指的是执行哪个对象的方法,根据对象的运行时类型来决定;执行对象的哪个方法,根据方法参数的编译时类型来决定。所谓 Double Dispatch,指的是执行哪个对象的方法,根据对象的运行时类型来决定;执行对象的哪个方法,根据方法参数的运行时类型来决定。 ↩︎

更多推荐

静态类型语言中类型的灵活性——泛型和变型

本文发布于:2024-03-07 14:27:04,感谢您对本站的认可!
本文链接:https://www.elefans.com/category/jswz/34/1718066.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文标签:类型   灵活性   静态   语言   变型

发布评论

评论列表 (有 0 条评论)
草根站长

>www.elefans.com

编程频道|电子爱好者 - 技术资讯及电子产品介绍!