如果像大多数人那样,把内存安全作为Rust的切入点,则不得要领。内存安全只是通过强类型确保安全性的一个具体应用而已,而Rust语言的方方面面无不渗透了这种设计模式,这才是Rust的本质。
类型概念
我发现,如果不正确理解“类型”(“Type”)在Rust语言中的含义,将会很难理解其他语言特性。可以说,通过类型来确保安全性是Rust的一个设计主题,基本上整个语言都是围绕着这种模式设计的。
我将用Python的例子来引入类型的概念(因为易于理解,而且Python是我的主要语言),同时介绍Rust中对类型的处理。
引子
比如要在代码中表示一个Email地址,我们可以用一个字符串来表示:“user@abc.com”。如果我们说字符串的类型是String,那么Email是否等价于String呢?不是的,因为“abc”显然是一个字符串,但是不是合法的Email地址 (Email地址的校验非常复杂,我们不在这里讨论)。那么,Email就类似于String的一个子集,它定义了一个类型。
所以类型我们直观可以理解为一个集合(可能是无限的)。
在通常的编程中,我们通常不会对Email单独定义一个类型,所以我们通常这么写:
def send_email(email: str, content: str) -> bool:
...
那么如果email包含的不是合法有效的Email地址呢(比如是空字符串)?我们抛出异常,或者直接挂(panic!), 或者说,这是 未定义行为 (Undefined Behavior,UB),任何事情都有可能发生(我作为函数的提供方,如果你传入的email不合法,对可能出现的后果不负任何责任,比如send_email会删库)!
为了确保这个地址有效,我们怎么办呢?
1)我们可以专门写一个函数,check_email:
def check_email(email: str) -> bool:
....
任何用到Email的地方,我都调用check_email。这是可以的,但是并不最优。是不是有可能我的email已经check过了?这么无脑调用确实比较浪费。
2)我在文档中注明,凡是调用send_email的,都要自己确保地址有效(隐性contract)。那是不是有可能某个程序员忘了check_email?这样会导致问题传播。
那么是否有更好的方法呢?当然有,让编译器(类型检查器)帮我们检查!
我们定义一个类型Email,并且把send_email这样定义:
def send_email(email: Email, content: str) -> bool:
...
那么编译器会保证,如果传入的email参数类型不是Email,就会无法编译(或者类型检查出错)!
那么使用这个函数的用户,就必须先创建一个Email,可能是这样:
class Email:
def __init__(self, email: str):
if _check_email(email):
# 严格的实现要求 Email 一旦创建不可直接修改
self._email = email
else:
raise ValueError(f"Invalid email {email}")
def _check_email(email: str) -> bool:
...
只要创建Email不报错,那么编译器就可以确保后面的所有使用都是没有问题的(因为有编译器的强类型保证)。 所以,我们把可能会有问题的部分局限在类型的构造函数里。后续的所有传递和使用都不用检查了,因为没有别的办法传入一个非法的Email。
类型和安全性
通过上述例子,我们介绍了一种非常通用的设计模式,通过强类型来确保安全性(safety)。
即把对数据有效性(安全假设,或者使用契约)通过类型来表达。只要你在使用一个类型的对象,那么它必然是符 合类型的前提假设的。那么之后的所有操作都可以通过类型一致性去强制,编译器很适合做这种事情。
比如,在Rust中,bool类型和u8(字节)都是一个字节,但是bool只能为0和1两个值,而u8有256个值。因此你从 bool 类型的值转换为 u8 是完全没有问题的,且是安全的。但是从一个u8转换为bool则是不安全的,编译器无法 知道你的u8具体包含什么,它只能假设各种可能性,因此就是不安全的,是未定义行为(UB)。而安全Rust(即 Safe Rust)的定义就是不允许有未定义行为。那么你如果非要从u8变出一个bool出来,则需要使用Unsafe Rust, 意思是说,这段代码我人工保证,校验了u8的值,虽然编译器不知道,但是我保证输出的一定是合法的bool值。
再比如,在Rust中,str必须是UTF-8编码,也就是说,不是任意的字节数组都是可以作为str的(跟我们的Email例 子很像)。所以Rust中,所有使用str的地方我们都可以假设它是合法的UTF-8编码的字符串,不用再二次校验。那么在运行时从Vec[u8]这样的一个字节向量中创建String,则必须使用Unsafe Rust。因为编译器无法校验你返回的东西是否真的是一个符合UTF-8编码的串。如果是字符串常量,编译器是可以在编译时校验的。
这样的原则可以扩展到内存安全性。其实Rust的引用类型就是一种有类型安全性保障的指针。或者说,引用是这样的指针:
- 不可以为空(创建时必须指向一个有效的目标,C++同样有这样的保障)
- 引用的生命周期必须在它指向目标的生命周期内(C++没有这个要求)
- 可写引用(&mut)只能创建一个(也就是同一时刻不能有两个&mut指向重叠的一片内存区域,C++没有这个要求)
符合这样的要求的指针就是合法的引用类型,在Safe Rust中是可以自由使用的(编译器保证的)。那么通常引用 是在运行时的,Rust编译器怎么在编译期就可以分析这些引用是否合法呢?Rust通过引入lifetime泛型参数(也是 一种类型)来参数化引用(这个后续系列会详细讲,你只要知道一切都是通过类型检查来确保的即可),从而把对 引用的生命周期分析转换为类型校验问题,然后用编译器来解决它。
可见,Rust的安全性就是通过把这个原则发挥到极致而实现的(比如说trait其实就是一种接口定义,它通过类型 规范对象的行为),以至于分化出了Safe Rust和Unsafe Rust。你如果知道这个大的原则,那么学习Rust就会顺利 很多,绝大多数概念就都容易理解了。
- 数据类型,规范内存数据的合法性,即对象内容
- trait,规范对内存数据的有效操作,即对象行为
- lifetime,规范内存引用(指针)的有效性,即对象生命周期
而如果像大多数人那样,把内存安全作为Rust的切入点,则不得要领。内存安全只是通过强类型确保安全性的一个具体应用而已,而Rust语言的方方面面无不渗透了这种设计模式,这才是Rust的本质。
Rust的整体设计思路受Typed Functional Programming影响很大,比较常见的是OCaml和F#。你如果用过这两种语言,对它们的编程模式很熟悉,则理解Rust会很容易。OCaml继承自ML,后者在学术上非常广泛,很多编程语言研究的论文都是用ML表达的。
Rust数据类型
简介
Rust是系统级编程语言,所以从内存角度思考类型是必要的。所以Rust的数据类型可以在一般性类型定义的基础上, 做具体化,即一个类型代表了一段连续内存中有效bit模式的集合。
比如上面提到的bool类型,它和u8都是一个字节,8个bit。u8中,所有bit都可以为0或者1,bool类型则只能最后 一位是0或者1,其他7个bit必须都为0。
类型决定了这段内存有多大,以及里面的内容如何解析。所以同样的一个内存地址,比如第128个字节,如果我 说它的类型是bool,则我知道它的长度是1,里面只可能是0或者1。如果它的类型是char,那么它的长度是4,且里 面的内容一定是一个有效的Unicode代码点(0x0到0xD7FF,0xE000-0x10FFFF,不可能是其他的值)。
所以只说类型,一定是针对一段内存说的,而不是抽象的东西。
Rust类型系统的另一个重要概念是固定大小和可变大小类型。如果一个类型的大小在编译期可以确定,那么就是固定大小,而在编译期大小不固定,则是可变大小类型(Dynamically Sized Type,简称DST)。比如基本数据类型 都是固定大小的,比如i8、i16、i32等这些整数,很明显。
str是可变大小的,因为你不知道一个字符串具体用多长来表示。
比如“hello”这个字符串,占用5个字节,且内容符合UTF-8编码,那它是一个str。 比如“hello,world”这个字符串,占用11个字节,且内容符合UTF-8编码,那它也是一个str。
所以我无法给定str一个具体的大小。
但是数组是固定大小的,比如你定义的时候,[u8;5]是数组类型,长度为5,可见数组的长度是包含在类型内的 (但是它对这5个字节的内容没有限制,什么都行)。长度不同,类型不同。
一个重要的原则,Rust中可以直接使用的类型 都是固定大小 的。因此,你无法在代码中直接声明一个变量是str:
// 无效代码
let a: str;
// 有效代码
let a: &str = "hello";
对于可变大小的类型,只能通过某种形式的指针(Box、&、&mut、raw pointer等)去间接使用它,因为指针是固 定大小的。所以你看到字符串的只有引用形式。
不固定大小的数组有吗?有的,就是slice,它表示一段内存区域,但是没有指定长度(编译期),所以它的类型 是[u8]这样,因为它没有长度,所以跟字符串一样,它只能以引用的方式存在,比如&[u8]。在运行时slice的引用 是一种胖指针,即同时包含了地址和长度。它的大小是普通指针的两倍。注意slice是一段内存区域,slice引用才 是带长度的指针。str可以理解为规定内容必须是UTF-8的[u8](u8 slice)。slice是C/C++中没有的概念,在进行 代码交互时需要注意。
Rust基础数据类型
我们这里简单介绍下Rust中语言级别提供基础数据类型。主要分为数字类型、文本类型、引用类型、数组类型。
数字类型
首先是数字,Rust中比较直接,用bit数表示一个整数的大小,分别为:
i8, i16, i32, i64, i128 (有符号的整数)
u8, u16, u32, u64, u128(无符号整数)
bool 是一个字节(同u8、i8),但是只能取值 0、1
IEEE浮点数如下:
f32 (32位浮点数)
f64 (64位浮点数)
表示平台内存地址或者偏移的整数:
isize (有符号版本,可以表示该平台的指针大小,因此平台相关,32位系统4字节,64位系统8字节)
usize(无符号版本)
上述两个类型对应到C/C++里的int类型和unsigned int。
文本类型
文本类型主要涉及:
str UTF-8编码的Unicode字符串,长度不固定
char Unicode代码点,4字节
引用类型
引用类型主要有:
-
&T: 对类型T的只读指针,且符合我们上述所说的引用原则
-
&mut T: 对类型T的可写指针,且符合我们上述所说的引用原则
-
*const T: 对类型T的只读指针,跟C/C++一样没啥限制(Unsafe Rust中使用)
-
*mut T: 对类型T的可写指针,跟C/C++一样没啥限制(Unsafe Rust中使用)
-
fn: 函数指针,指向内容是一段可执行代码,代码只能只读,因此无所谓mut,类型包括函数的签名,比如 fn(u8)->bool
数组类型
这是语言内置的组合类型:
-
slice: 如上文所说,表示一段连续内存,[T]
-
array: 如上文所说,表示一段固定大小的连续内存,[T;N]
-
tuple: tuple表示一段内存,由不同类型组成,比如(u8,u16,f64)表示一段内存,大小是16个字节(Rust会考虑对齐,所以本来是1+2+8=11字节,但是为了对齐所以扩展成了最大元素8的整数倍,即16字节;即Rust不对内 存中tuple成员的顺序和具体位置做保证),数组可以理解成tuple的特例,比如[u8;4]可以理解为(u8,u8,u8,u8), 但是注意这只是概念上的理解,这两个仍然不是可以互相转换的
特殊类型:unit和never
有一个特殊类型叫做unit,写作“()”(括号),这个类型只有一个值,也写作“()”(括号)。这个类型大小为0, 不对应内存位置,只是一种类型系统上的结构。比如,如果一个函数不返回任何具体的值(调用只是为了它的副作 用),则它的类型是“()”:
fn only_side_effect() -> () {
println!("hello");
}
还有一个类型叫做never,写作“!”(感叹号),没有值,比如函数exit,调用之后就进程退出,永远不返回,则其 返回类型是“!”:
fn exit(code: i32) -> !;
小结
好的,上述就是Rust的全部基础的内置类型了。可见,Rust对所有类型的内容、行为均有非常明确的定义。所以它能消除非常多的未定义行为,安全性就有了保障。Rust的自定义数据类型(struct、enum、union等)后续会介绍。