现代化程序开发笔记(12)——泛型与多态

本系列文章以我的个人博客的搭建为线索(GitHub 仓库:Evian-Zhang/evian-blog),记录我在现代化程序设计中的一些笔记。在这篇文章中,我将介绍的是泛型与多态。

共性与标准

我们为什么要使用泛型与多态?这些诡异的中文译名究竟指的是什么?我觉得,要解释这个问题,就需要指出我们实际编写项目时需要的共性与标准。

假设我们在编写一个快餐店的程序。快餐店中,有服务员、薯条厨师、汉堡厨师和炸鸡厨师,在我们的程序中,这每一个职业都被实现为一个类,这个思路是很自然的。每个职业除了都有自己个性的工作,比如说服务员端菜、薯条厨师炸薯条,还应该有一些共性的事。比如说,这些职业都是我们这个快餐店的员工,所以需要发工资。我们发工资的工序很简单,先看这个员工这个月请了多少天的假,再根据上班天数发对应职业的工资。也就是:

  1. 查看这个员工这个月请假次数
  2. 查看这个员工的工资标准
  3. 发工资

无论是什么员工,发工资的操作总是类似的,这就是共性。同时,每个员工,我都应该有能力查看他的请假次数和工资标准,只有能够满足这样要求的人,才能成为发工资的对象,这就是标准。为了共性和标准,我们希望在编写代码的时候能满足两个条件:

  • 同样的工序不写多次
  • 只有符合条件的才能调用函数

也就是说,我希望我写的函数不能只给一个特定的对象用,因为还有很多和它有共性的对象能一起用;但是,我写的函数也不能谁都用,只有满足特定条件的对象才能用。这就是泛型与多态的目的。

具体而言,我有如下的类(以Rust为例,而Swift,Kotlin,TypeScript等中都有类似的设定):

struct Waiter;
struct ChipsCook;
struct HamburgerCook;
struct ChickenCook;

我需要什么呢?我需要的是一个用来描述它们共性的标准,在Rust中,就是trait(在别的语言中可以是接口或基类,但在理解上没有什么区别):

trait Employee {
    fn absence_days() -> usize;
    fn wage_level() -> usize;
}

我们需要提供这样一个接口,然后让每一个类去实现它:

impl Waiter for Employee {
    fn absence_days() -> usize { /* hide */ }
    fn wage_level() -> usize { /* hide */ }
}

像这样对Waiter, ChipsCook, HamburgerCook, ChickenCook都需要实现这样的接口,就表示这些类都是我们快餐店的员工,这就是解决了“标准”的问题。

接下来,就是解决共性的问题,怎样只写一次代码,却能对我们快餐店的员工都通用呢?

静态分派与动态分派

直觉告诉我们,我们应该写一个类似这样的函数(先不管语法):

fn pay(employee: Employee) {
    let absence_days = employee.absence_days();
    let wage_level = employee.wage_level();
    // do something else
}

这么做显然是符合逻辑的。首先,我们需要的是快餐店员工,不是快餐店员工的人不能使用这个函数。然后,我们需要查看他请假天数和薪资标准,由于我们的几个职业都实现了这个接口,所以我们可以调用这些函数了。这看上去很简单,之前的步骤也很符合我们的逻辑。但是,仔细看,「我们就可以调用这些函数了」,究竟是怎么调用?

我们知道,函数实际上就是一个代码段,我们要调用函数,就是告诉CPU从某个地址开始执行相应的代码。然而,对于不同的职业,它的absence_days都是不同的,CPU怎么知道该执行谁的代码?这就涉及到了静态分派与动态分派的问题。

静态分派

最简单的,在编译期,编译器是知道类型信息的。比如说我们这样调用:

let waiter = Waiter { };
pay(waiter);

编译器既然知道它调用的是什么类型,那么有没有办法给它指定呢?答案是像这样:

fn pay<E: Employee>(employer: T) { /* ... */ }

let waiter = Waiter { };
pay(waiter);

编译器实际做了啥事呢?我们可以理解成,当它看到我们调用了pay(waiter),并且waiter已知的类型是符合我们传参的要求时,就为Waiter类型生成一个函数

fn pay(employee: Waiter) { /* ... */ }

这样的话,CPU自然就可以知道该调用哪个函数了,因为Waiterabsence_days只有一个。这种在编译期就确定的手法就叫作静态分派。

动态分派

然而,有的时候并不遂人意,我们并不能在编译期确定所有的类型。我们想要给目前离我们最近的一个员工发工资,假设我们就用了一个get_most_close_employee函数,来获得目前离我们最近的一个员工。由于这样的员工有可能是任意一个职业,所以这个函数返回的,可能是任何一个快餐店员工的职业。用Rust的语言来说,我们得这样:

fn get_most_close_employee() -> Box<dyn Employee> { /* ... */ }
fn pay(employee: Box<dyn Employee>) { /* ... */ }

let employee = get_most_close_employee();
pay(employee);

由于Rust的限制,我们只能把这样动态返回类型的函数的返回值放在Box里。我们通过加上dyn关键字,就能实现一个动态类型了。那么编译器要做什么呢?编译器就没刚刚做的那么复杂了,直接把Box传递就好了,并不做什么生成代码的事。然而,CPU执行的时候,就会比较累了。执行的代码我们之前说过,并不能直接写一个函数地址,因为有多个函数。然而我们在生成这样地址的时候,也就是编译期,仍然不知道准确的类型,也就不知道应该生成的是哪个函数的地址。因此,大多数语言中都会对这类关系生成一个虚函数表,需要确定执行哪个函数的时候,只需要查表即可,这就是动态分派。

也就是说,由于在CPU执行的时候是没有类型信息的,所以静态分派和动态分派分别是这样的情形:

在静态分派的情况下,CPU坐在店里,这时一个对象进来了,并且身上写了需要CPU执行的函数,CPU就直接执行了;在动态分配的情况下, CPU坐在店里,这时一个对象进来了,CPU问“我要执行你的xx函数,告诉我从哪开始执行”,对象指了指某个地方,然后CPU再执行。也就是说,在动态分派的情况下,CPU需要有一个询问——应答的过程,也就会产生一些性能上的损耗。但是,动态分派也更加灵活一些。

泛型返回类型与不透明返回类型

我们发现,在上面讨论的静态分派中,尽管函数定义的时候用了泛型,如

fn pay<E: Employee>(employer: T) { /* ... */ }

但是我们实际调用函数的时候,并没有显式写出某些泛型的特化类型,而是只是传参进去:

let waiter = Waiter { };
pay(waiter);

我们可以确保这样是对的,因为编译器是知道waiter的确切类型的,所以知道这个泛型应该特化成哪个类型。然而,这件事,到了返回值上就不一定了。

泛型返回类型

假设我们现在需要一个函数用来招聘员工,我们希望的是写一个类似这样的函数(不管语法):

fn recruit_employee() -> Employee;

通过调用这个函数,我们可以得到新的员工。同时,由于我们是老板,我们想要指定招收的是哪个职位的员工,也就是说,我们想用上面的这种函数,来完成下面这些函数做的事:

fn recruit_waiter() -> Waiter;
fn recruit_chips_cook() -> ChipsCook;
fn recruit_hamburger_cook() -> HamburgerCook;
fn recruit_chicken_cook() -> ChickenCook;

这些工作除了招收的职务有不同以外,其工序都是类似的。也就是说,我们仍然希望用泛型和多态来完成我们对同一工序不写多次的需求。模仿之前的静态分派,我们可以写出这样的代码:

fn recruit_employee<T: Employee>() -> T;

然后这样调用:

let waiter: Waiter = recruit_employee();
// or
let waiter = recruit_employee::<Waiter>();

不管哪种方法,都是需要显式写出类型的。这就是泛型返回类型的作用。

泛型返回类型虽然并不如泛型参数在项目中来的普遍,但是有一种情况往往是不可避免地使用泛型返回类型,那就是反序列化时。我们通过一个JSON字符串,需要得到一个对象,那么就需要反序列化。然而,得到对象的类型却是不定的,也就需要用户自己指定,这就需要了泛型返回类型。

不透明返回类型

除了上面的静态分派的泛型返回值类型,和我们讲到动态分派时提到的返回Box<dyn Employee>的动态分派的返回值以外,还有一种比较少见的情况,但也是一个很必要的语法支持,在Rust和Swift中都有体现,那就是不透明返回类型。这种需求一般在写库的时候会用到。

假设我们的快餐店开大了,开了好多家分店。那么,我们需要管理这些分店,所以最直接的方法,就是设置一个快餐店分店的接口,让所有我们的分店去实现:

trait Branch { /* ... */ }

最基本的,一个分店得有一个店长,这个店长是在员工之中选出的。所以,我们希望有一个类似以下的函数(不管语法):

trait Branch {
    fn get_manager() -> Employee;
}

这和我们刚刚泛型返回类型的需求差不多嘛!然而仔细一看,其实并不一样。泛型返回类型中,是调用者决定返回值的具体类型,然而这里却不是。我们不需要指定这家分店必须是服务员当店长还是汉堡厨师当店长,这些是由被调用的函数内部决定的,所以,我们不能用泛型返回类型来决定。这里,就需要不透明返回类型:

trait Branch {
    fn get_manager() -> impl Employee;
}

当我们对一个具体实现Branch的分店调用这个函数的时候,得到的是一个实现Employee接口的对象,然而它究竟是什么具体的类型我们并不能知道,所以就叫不透明返回类型。

在Swift中,也有类似的语法。它最著名的例子,就是我们的SwiftUI。在SwiftUI中,我们每一个View都必须有一个可计算属性body:

struct MyView: View {
    var body: some View
}

这里的some关键字就类似于Rust中的impl,提供一个不透明类型。

协变与逆变

Kotlin对这一特性有支持,所以我就以Kotlin为例。

我们的服务员Waiter,薯条厨师ChipsCook等都继承于员工类Employee。现在有一个接口,可以批量招聘员工:

interface Recruiter<T> {
    fun recruit(): T
}

然后我们可以实现这样的类:

class EmployeeRecruiter : Recruiter<Employee> { /* ... */ }
class WaiterRecruiter : Recruiter<Waiter> { /* ... */ }
class ChipsCookRecruiter : Recruiter<ChipsCook> { /* ... */ }

我们希望

val myRecruiter: Recruiter<Employee> = WaiterRecruiter()

我们知道,把Waiter类型的变量赋值给Employee类型的变量显然是可以的,然而向上面这种写法却不能通过编译。但是,我们仔细想想,我们如果能赋值,并不会产生什么错误,因为我们要用也是

val anEmployee: Employee = myRecruiter.recruit()

依然是将派生类赋值给基类,始终不会把基类赋值给派生类产生错误。那为什么不让我们通过编译呢?

因为编译器不知道你的Recruiter接口会实现怎样的函数,如果有一个这样的函数:

interface Recruiter<T> {
    fun introduce(t: T)
}

介绍优秀员工,那么像我们刚刚这么调用:

val myRecruiter: recruiter<Employee> = WaiterRecruiter()
val anEmployee: Employee = // ...
myRecruiter.introduce(anEmployee)

实际上就是把Employee类型赋值给了Waiter类型,就会导致错误。

习惯上,把第一种,返回泛型类型的接口称为Producer,把第二种接受泛型类型参数的接口称为Consumer,那么,如果一个接口只是Producer,那么可以把派生类泛型的接口赋值给基类泛型而不出错,就像我们刚刚讨论的那样,这就叫协变。而如果一个接口只是Consumer,事实上我们可以把基类泛型的接口赋值给派生类泛型而不出错,这就叫逆变。

在Kotlin中,我们可以用out关键词保证协变,用in关键词保证逆变:

interface Producer<out T> {
    fun produce(): T
}
interface Consumer<in T> {
    fun consume(t: T)
}

通过加入关键词,我们就可以顺利地赋值了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值