likes
comments
collection
share

Rust中的New Type解答了我十多年前的一个疑问

作者站长头像
站长
· 阅读数 4

Rust中的New Type解答了我十多年前的一个疑问

十多年前还在学校念书的时候,记得不是在C语言的课上,就是在数据结构的课上,反正是编程刚开始入门的时候,看到过类似这样的C语言代码,

#include <stdio.h>

typedef int Age;
int main() {
    Age age = 18;
    printf("age = %d\n", age);
}

当时就产生了一个疑问:年龄不就是一个整数吗,为什么还要特意定义一个Age类型,或者说给出一个int的别名呢?看起来实在是多此一举,因为赋值时还不是要直接给出一个整数(这里是18)?

翻阅谭浩强编纂的C语言经典教材《C程序设计》,书中给出的解释是为了顾名思义、一目了然

Rust中的New Type解答了我十多年前的一个疑问

我当时觉得也没有提升多少可读性吧,还要翻到源文件的开头才知道Age这个类型怎么定义的,然后才能知道=后面可以给出什么样的值。

工作后,我很长一段时间都在使用全世界最好的语言,PHP这种动态脚本语言根本不需要声明变量的类型,自然也就不用考虑是int $age好啊,还是Age $age好了。

这两天看到Rust语言中的New Type Idiom(New Type也写作Newtype),好像突然想明白定义一个Age类型有什么好处了!下面就来和大家分享一下自己的看法。

New Type也是给编译器看的

Rust中的New Type是指通过使用元组结构体包裹已有的类型(通常是一个基础数据类型)来定义出的新类型,例如struct Age(i64);,这里的struct Age()是元组结构体,包裹的i64是已有的基本类型(primitive),Age是由此定义出的新类型。

Rust的参考文档(New Type Idiom)中还单独用一小节来介绍这种模式,文中写道:

The newtype idiom gives compile time guarantees that the right type of value is supplied to a program.

这里特别强调New Type能够在编译时确保类型正确。

为了看清引起编译器报错的部分,我们先来简化一下New Type Idiom这个页面中的示例。

// https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=4699a650d291b6ca00b9e6078d02c288

struct Age(i64);

fn old_enough(age: &Age) -> bool {
    age.0 >= 18
}

fn main() {
    let age = Age(5);
    println!("Old enough {}", old_enough(&age));
    println!("Old enough {}", old_enough(&18i64));
}

运行后,会发现如下所示的报错信息,

Compiling playground v0.0.1 (/playground)
error[E0308]: mismatched types
  --> src/main.rs:14:42
   |
14 | println!("Old enough {}", old_enough(&18));
   |                           ---------- ^^^ expected `&Age`, found `&i64`
   |                           |
   |                           arguments to this function are incorrect
   |
   = note: expected reference `&Age`
              found reference `&{integer}`
note: function defined here
  --> src/main.rs:3:4
   |
3  | fn old_enough(age: &Age) -> bool {
   |    ^^^^^^^^^^ ---------

报错的原因是old_enough()需要类型为&Age的参数,而给出的参数&18的类型却是&i64。尽管Age封装了i64,但二者(的引用)却是不同的类型。New Type不仅是为了程序员能“一目了然”(了不了然可能是因人而异,视情况而定),更能利用编译器的类型检查,尽早发现类型不匹配的错误。即保障了所谓的类型安全(type safety)。

在C语言,除了用typedef int Age;定义类型别名,也能利用struct定义出New Type,让编译器帮我们检查类型不匹配的错误。例如,

#include <stdio.h>

typedef struct Age {
    int value;
} Age;

int old_enough(Age age) {
    return age.value >= 18;
}

int main() {
    Age age = {18};
    printf("Old enough %d\n", old_enough(age));
    printf("Old enough %d\n", old_enough(18));
}
/cplayground/code.c: In function ‘main’:
/cplayground/code.c:17:39: error: incompatible type for argument 1 of old_enough’
   17 |  printf("Old enough %d\n", old_enough(18));
      |                                       ^~
      |                                       |
      |                                       int
/cplayground/code.c:10:20: note: expected ‘Age’ but argument is of type ‘int’
   10 | int old_enough(Age age) {
      |                ~~~~^~~
                  Execution finished (exit status 1)  

Go语言也能利用struct定义出New Type,但在Go语言中更常见的还是类型别名。

类型别名

在Rust中,也能定义与C语言和Go语言类似的类型别名。

我们稍稍修改一下刚刚的示例,

// https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=6abd9b7b7afbc04ad9c319f88ffc533a

type Age = i64;

fn old_enough(age: &Age) -> bool {
    *age >= 18
}

fn main() {
    let age1: i64 = 10;
    println!("Old enough {}", old_enough(&age1));

    let age2: Age = 18;
    println!("Old enough {}", old_enough(&age2));

    println!("{}", age1 + age2);
}


// Old enough false
// Old enough true
// 28

可以看到虽然age1的类型是i64age2的类型是Age,但这次编译器没有因这是”两种类型“而报错。也就是说,类型别名终究只是别名,并不是一个(对于编译器来说)全新的类型

既然New Type和类型别名都可以定义出“新”类型,那二者有什么不同呢?下面就来通过一个具体的应用场景来对比它们。

给数字加单位时,用New Type好,还是类型别名好?

在本节中,我们一起来对比一下Go中的time.Duration类型和Rust中的用New Type自定义的Duration类型,看一看给数字加上单位时,是用New Type好,还是类型别名好?

Go中time.Duration类型的定义是,

// https://github.com/golang/go/blob/master/src/time/time.go#L618

// A Duration represents the elapsed time between two instants
// as an int64 nanosecond count. The representation limits the
// largest representable duration to approximately 290 years.
type Duration int64

Duration类型是int64类型的别名。接下来,Go基于Duration定义出了一系列时间段的单位

// ...
// To convert an integer number of units to a Duration, multiply:
//
//    seconds := 10
//    fmt.Print(time.Duration(seconds)*time.Second) // prints 10s
const (
    Nanosecond  Duration = 1
    Microsecond          = 1000 * Nanosecond
    Millisecond          = 1000 * Microsecond
    Second               = 1000 * Millisecond
    Minute               = 60 * Second
    Hour                 = 60 * Minute
)

这样做的好处是大幅提升了与时间段有关的函数参数的可读性,例如time.Sleep(d Duration)net.DialTimeout(network, address string, timeout time.Duration) (Conn, error)等等。

如果把这里的参数类型由time.Duration改为int64,那么对于time.Sleep(1),就不禁会产生疑问:这是要睡1秒呢?还是1毫秒呢?还是其他单位呢?于是不得不每次都去翻阅API文档来确认时间段的单位。这远没有 time.Sleep(2 * time.Second)来得直观。

不过,由于Duration只是int64的别名,编译器无法拒绝、禁止time.Sleep(2 * 1000 * 1000 * 1000)这样的写法(已知time()的参数类型Duration是以纳秒为单位的,所以2秒就是2 * 1000 * 1000 * 1000纳秒)。

而且,编译器不区分Durationint64还会导致更加荒谬的结果。比如,时间段可以相加或相减,但相乘应该没有意义吧,

v := 1 * time.Second * 2 * time.Second
fmt.Printf("%T, %v, %#v, %+v", v, v, v, v)
// time.Duration, 555555h33m20s, 2000000000000000000, 555555h33m20s

也就是说,要写成<num> * time.<Unit Constant>的形式,以及时间段不能相乘,这些规则都成了Go程序员需要遵守的约定,Go编译器可不管这些。

想一想,以下Go代码输出什么?

type A int
type B int

func main() {
    fmt.Println(A(1) + B(1))
}

我们再来看看Rust中的情况。

在Rust中,可以利用New Type模式这样定义时间段,

struct Duration(i64);

这样定义主要是为了和Go语言中的Duration对比,Rust的标准库可不是这样定义的(Rust标准库中的Duration类型)。

首先是要解决如何带着单位、直观表示一段时间的问题。由于Rust的编译器会认为i64和基于它定义的New Type Duration已经不是同一类型了,所以Go代码中的<num> * <Unit Constant>的写法恐怕不行。不过,我们可以用Duration上的方法解决这个问题,通过方法的名字提示数字的单位,

#[derive(Debug)]
struct Duration(i64); // 与Go的`time.Duration`相同,也是以纳秒为单位

impl Duration {
    fn from_seconds(seconds: i64) -> Self {
        return Self(seconds * 1000 * 1000 * 1000);
    }
}

fn main() {
    println!("{:?}", Duration::from_seconds(3));
}

再来试试时间段相乘的问题,

println!("{:?}", Duration::from_seconds(3) * Duration::from_seconds(3));

//   println!("{:?}", Duration::from_seconds(3) * Duration::from_seconds(3));
//                    ------------------------- ^ ------------------------- Duration
//                    |
//                    Duration
  
// note: an implementation of `Mul` might be missing for `Duration`

可以看到,编译器直接报错了,就算程序员不知道时间段不能相乘这样的常识,也不会产生荒唐的结果了。

再把*改成+试试呢,又报错了。

error[E0369]: cannot add `Duration` to `Duration`

但这又是我们不希望看到的,时间段是可以相加的。解决这个问题也不难,只需要为Duration实现加法对应的trait Add

// https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=4adc491fb207024b4e9fff5825558cb2

#[derive(Debug)]
struct Duration(i64); // 与Go的`time.Duration`相同,也是以纳秒为单位

impl Duration {
    fn from_seconds(seconds: i64) -> Self {
        return Self(seconds * 1000 * 1000 * 1000);
    }
}

use std::ops::Add;
impl Add for Duration {
    type Output = Self;

    fn add(self, rhs: Self) -> Self::Output {
        return Self(self.0 + rhs.0);
    }
}

fn main() {
    // println!("{:?}", Duration::from_seconds(3) * Duration::from_seconds(3));
    println!(
        "{:?}",
        Duration::from_seconds(3) + Duration::from_seconds(3)
    );
}

通过对比可以发现,使用New Type表示数字的单位时,编译器承担了检查运算是否有意义的工作,而这部分工作之前是常识或者是程序员之间的共识。

表示时间段的数字是整数,用面向对象的语言说就是,时间段是整数的子类,而后者可以加减乘除,前者却只能加减。这个现象说明,虽说子类不一定充分扩展了父类,但至少也是龙生龙凤生凤、老子英雄儿好汉,子类得和父类有一样的行为、功能,即所谓的里氏替换原则。但事实上,有时却要限制、缩小子类的行为,或者子类反倒有更多的限制和约束。回到文章一开始年龄的例子,年龄是整数,但年龄的取值范围绝大多数情况下都要比u8/uint8小吧。

到底是用New Type表示单位好,还是用类型别名表示单位好,可能取决于是要求、希望程序员区别对待两种相似的类型(不区分也不太可能出错)呢,还是由编译器区分,进而因为报错信息使得程序员不得不区分。

小结

本文简单介绍了两种定义“新”类型的方法,New Type和类型别名,并通过时间段Duration这个类型,对比了二者的区别。

New Type的主要用途有以下几点,

  • 用更加有意义,更具描述性和表现力的词语作为类型名,提升可读性(对比intAgestringUserEmail
  • 类型检查、类型安全(struct T(t)Tt是不同的类型)
  • (可能是Rust独有的)为外部的类型实现外部的Trait,如为std::string::String实现std::fmt::Display
  • 定义单位,明确数字的含义
  • 封装、抽象私有的内部类型,并可以将校验逻辑绑定到类型上(如作为身份证号的字符串必须是18位)
  • 缩减、限制类型的行为(如作为时间段或年龄的整数不能相乘)

New Type的一个缺点是,在重构代码时,若要使用New Type struct T(t)替换基础数据类型t,如用struct UserId(String)替换作为用户ID的String,那么就不得不沿着函数的调用链,逐一修改每个函数的参数。

而类型别名的主要用途包括:

  • 用更加有意义,更具描述性和表现力的词语作为类型名,提升可读性(在这一点上与New Type一样)

  • 缩短冗长的类型定义,减少重复出现的类型

有些New Type的用途文中并没有提及,关于这些内容,大家可以参考以下文章

  • doc.rust-lang.org/rust-by-exa… 《Rust By Example》 New Type Idiom。简单介绍了什么是New Type

  • course.rs/advance/int… 《Rust语言圣经(Rust Course)》 4.3.2 newtype和类型别名。介绍并对比了New Type和类型别名,语言幽默

  • doc.rust-lang.org/book/ch19-0… 《The Rust Programming Language》 19.3 Advanced Types。介绍并对比了New Type和类型别名

  • rust-unofficial.github.io/patterns/pa… 《Rust Design Patterns》3.1.3. Newtype。简单例举了New Type的优缺点,并以作为密码的字符串为例,介绍了如何使用New Type模式为String类型实现Display这个特征(trait)

  • www.worthe-it.co.za/blog/2020-1… The Newtype Pattern In Rust。以存储用户信息的struct Person为例,说明了利用New Type、而不直接使用Stringu32等定义用户的IdNumberYears等属性的好处,并介绍了通常要为New Type实现的trait。