
昨天我们学习了模式和匹配
今天我们来接着往下学
advanced features)其实到昨天我们已经学完了rust大部分常用的知识了,在开始做一个web项目之前,我们需要了解一下会用到但是不是很常用的功能。
这些功能在某些场景下会非常的好用。
在这一章节里我们将会接触到以下几个知识点:
unsafe:可以回避rust的某些规则,但是需要自己维护安全性等。traits: 关联类型(associated type) 、默认类型参数、完全限定语法(fully qualified syntax)、supertraits(这个真不知道咋翻译了。。)、和traits相关的新类型模式(newtype pattern) 。types): 深入的了解新类型模式(newtype pattern)、类型别名(type aliases)、绝不类型(the never type)、动态大小类型(dynamically sized types)。function pointer)和返回闭包(return closures)。macro): 一种定义代码的方法,这些方法会在编译的时候定义更多的代码(ways to define code that defines more code at compile time)。目前我们代码都是基于内存安全的,并且会在编译阶段进行限制报错不安全代码。
不过rust还内置隐藏了一个(second language)第二语言,它不会强制要求内存安全。
它就是unsafe Rust, 写法和安全的差别不大,但是可以让我们使用额外的”超能力(superpowers)“。
为啥要内置一个unsafe Rust呢?
首先,静态分析(static analysis)都是保守的(conservative)。
对于编译器来说,你这个代码它如果看不懂,没法确定是否处于安全状态,那直接乱棍打死。宁可杀错,不可放过,即使你这个代码绝对没问题。
所以这个时候就需要unsafe Rust,告诉编译器: Trust me, I know what I’m doing.
不过这也就意味着内存安全得由我们自己来控制了,这有可能会出现问题,比如空指针引用等。
另一方面,rust需要和底层硬件沟通,而底层硬件就是unsafe的。rust需要提供开发者使用低级语言(low-level system programming)编程的能力,比如和操作系统对接甚至写一个自己的操作系统。
实际上我们标准库里的部分代码就是unsafe的。
unsafe superpowers使用不安全能力的关键字是unsafe,然后后面再接一大括号: unsafe { ... },然后将你的不安全代码放到这个unsafe后面的{}大括号里。
在不安全区域中,我们可以使用”超能力“,它拥有以下能力
raw poiners)static variable)unsafe的traitunion的字段需要注意,除了以上五个超能力之外,其它的还是safe的,也就是说编译器依旧会check你这段代码,但是会过滤使用上面的五个能力的场景。
还有一点需要知道,那就是不是所有你认为不安全的代码都要放到unsafe块里的,只有涉及到内存不安全的代码才需要用unsafe包裹。通过unsafe关键字,当内存有问题的时候我们可以快速定位到是哪里的代码有问题。
我们需要把unsafe代码独立起来,尽量不要和上下文相关联。最好的独立方式是抽象(封装)这块unsafe代码,然后提供safe api暴露出来,这样做会相对安全些,我们等会会接触到。
raw pointers)之前我们接触到的指针在编译器的加持下,都能确保有效(包括循环引用)。
而在unsafe中提供了两个新类型,其中一个是原始指针(raw pointer)。
原始指针也可以是可变或者不可变的,关键字分别是*mut T和*const T。T是类型,另外这里的*符号和解引用的*无关,它是这个关键字的一部分。
在使用原始指针的上下文环境中, 原始指针的不可变意味着在解引用之后它将无法直接被分配出去。
到这里我们接触过三类指针,分别是引用、智能指针以及原始指针,而原始指针相对于另外两个指针有以下几个不同点。
null。可以看到了我们失去了很多东西,换来的是跟好的性能或者和别的语言/硬件的交互能力。
话不多说,我们直接来看个例子
规范是把引用as成raw pointer,表示这个引用变成了原始指针。
可以看到这里同时存在可变引用和不可变引用并且没有报错,另外声明原始指针并不需要包裹unsafe。
不过如果要解引用它的话就需要unsafe包裹
把代码改为下面这样就不会报错了。
这俩原始指针在这个例子中指向的内存空间是有效的,我们再来看个无效的。
我们随便写了一个内存空间的地址,然后标记它为原始指针。
它可能是有效的,但更大的可能是无效的。
创建一个原始指针并不会有很大的问题,唯一有问题的就是这个指针可能指向无效的值。
不过需要注意可能会导致数据竞争的问题。
为啥要冒这么大的风险去用原始指针呢?
一方面是因为有时候需要对接其他语言,比如C语言,我们等会会接触到。
另一方面就是我们的代码确实是安全的,但是编译器不知道,所以得用原始指针来处理。
unsafe函数/方法unsafe上下文提供的另一个新类型是不安全函数(unsafe functions)。
不安全函数和常规的函数/方法没多大差别,不过它们并不安全,需要用不安全关键字unsafe定义。
既然定义是不安全的,那使用起来自然就得用unsafe {}包裹才行。
直接来看下例子
定义用unsafe,使用也用unsafe。
当你在代码中使用了这个不安全的函数时,这就意味着你看过了这个函数的文档,知道它的具体用法。
另外在不安全函数中使用不安全相关的功能不用再使用unsafe包裹,因为这个函数自身都是不安全的。
safe abstraction)来封装不安全代码我们可以给函数加上unsafe标签来表示这个函数里的东西都是不安全的,而反过来并不一定需要使用unsafe标签定义函数。
比如你只有一小块代码是unsafe的,这个时候就没必要把整个函数都用unsafe包裹了。
实际上把不安全代码封装到安全的函数里是一种常见的安全抽象。
我们以标准库中split_at_mut这个方法为例子,它里面就有一些不安全代码。它将一个可变的切片引用根据下标切割成俩部分并返回一个元组。
我们先来调用下
然后我们来试着实现一个我们自己的split_at_mut
这里省事不用method,直接传一个可变的切片进来就行。我们使用了引用参数,并且返回的值和这个引用参数是有关的,按理说是需要注释生命周期的,不过我们只有一个引用,按照三板斧的第一板斧(只有一个引用参数时即input lifetimes,默认会分配给所有output lifetimes)这里符合,所以就省略了。
不过这段代码还是报错了,相信大家也都知道是为什么报错。
存在多个可变引用。
但是我们的代码理论上是没有问题的,因为它切割的两部分是没有关联的。
但是borrow checker或者说是编译器并不清楚,它只知道你这个values被可变引用了两次,这个时候自然就报错了。
这种时候如果不使用unsafe的话,我们只能把mut去掉才行。
但是这样就不符合要求了。要么就绕远路,加个标志位判断是截取前面还是后面,一次只返回一个,但是在使用的时候也只能存在一个可变的。
所以这个时候就unsafe就很有用了。
我们改成用unsafe包裹返回的样子
没想到还是报错了
还是原来的报错,为什么呢?
因为我们返回的是引用,而不是原始指针。
还记得之前我们要牢记的一点么?unsafe不代表可以完全不遵循rust的规则,只有5点能力是可以规避规则的,我愿称之为战五渣。
我们来改成原始指针的方式:
as_mut_ptr这个方法返回一个可变的原始指针
这个原始指针指向这个切片的内存地址。
而slice::from_raw_parts_mut这个方法则是接收这个指针和索引,返回一个根据原始指针指向这个位置和索引的位置(因为切片在内存中是连续的)截取的可变引用切片。
这里还有个生命周期,因为要求数据在使用上下文中不会失效。
实际上这个返回的切片引用和原来的切片引用没多大关联了,你可以把它当作另一个切片的引用,是这个原来的子集的切片的引用。
注意这里返回的是一个数组,不能删除或者添加元素,仅可以替换相同类型的元素。
为什么要这么做呢?因为为了确保你这个引用是有效的,我们是通过引用 + 索引的方式截取的子集,如果这个引用长度变了,这个子集里的元素就不一定都是有效的了。
这个方法是不安全的,因为需要开发者自己去确定这个数据是正常的。
而add方法则是将这个原始指针的位置偏移索引值个位置,前面也说过了切片是连续的。
这个方法也是不安全的,因为也得开发者去确定传入的mid偏移量是命中这个内存的。
那现在就应该没问题了,来运行下
rust还提供了和其他语言交互的功能。
我们一般管这叫*Foreign Function Interface (FFI).* 也就是外部功能接口,顾名思义,我们定义了的方法可以被其他语言调用。
我们直接来看个例子
关键字是extern,而C则表示是C语言。我们调用了C语言标准库中提供的abs方法。
你应该注意到了我们用unsafe包裹再调用。
因为外部的东西都是不安全的,它们没有所有权等规则,rust编译器没法判断它们是否是安全的,很有可能会引起兼容性问题等。
这个C的部分是*application binary interface (ABI)* 也就是应用程序二进制接口,它定义了如何在程序集级别调用外部函数,rust中的这个C的ABI是最通用的并且支持C语言的ABI。
既然有使用外部函数的方式,那自然也有提供给其他语言调用的方式。[8]
直接来看下例子
我们用#[no_mangle]来声明这个函数是用来提供给其他语言对接的,这样编译器就不会对它mangling也就是矫正。
所谓的矫正就是编译阶段编译器会把这个函数的名字做处理成更多上下文信息的名字,但是对于开发者来说是没有阅读性的,可以简单的理解为前端中的uglify[9] 处理后的js代码。
然后我们给要暴露到外面的函数加上了extern Target的关键字,让它能被暴露出去。
往外暴露的不要求unsafe包裹。
目前我们并没有接触过全局变量(global variables,在rust中叫静态变量static variables)(之前接触过环境变量),实际上rust是支持的,但是因为所有权规则,就会很容易报错,比如数据竞争的问题,俩线程同时访问这个全局变量。
我们直接看例子
关键字是static,之前我们也用过static这个关键字,在学生命周期的时候,output lifetimes中可以使用'static关键字告知编译器这不是悬浮指针。
另外命名规范是和常量一样的大写字母加下划线分隔。这么一看,静态变量和常量挺像的。
静态变量仅可以存储具有'static生命周期的引用,也就是编译器可以推断出生命周期,我们不用去给它一个显示的注释表达这是个静态变量。
访问不可变的静态变量是安全的。
这么一看,(不可变)静态变量和常量更像了。
实际上还是有那么一点差别的。
静态变量在内存中的地址是固定的,访问这个数据永远都是相同的数据。
而常量则允许在调用它的时候复制它的值。
也就是唯一性的问题。
还有一点就是这个静态变量是可以可变的,虽然这么做不安全。
来看个例子
和可以变量一样,关键字都是mut。
我们在add_to_count这个方法中改动了这个静态变量。来运行下看是否正常。
由于静态变量是全局的,并且是唯一的,所以很难保证在可变的情况下不会产生数据竞争的问题。
trait和不安全函数一样,不安全trait也是unsafe标识,另外实现它的时候也要用unsafe才行。
如果你的这个trait中有一丁点东西是编译器无法校验的,那么你这个trait就是unsafe的(当然,错误的写法导致报错不算)。
来看下例子
还记得我们之前学的Sync和Send吗?如果一个类型完全由Sync和Send组成,那么这个类型也会是Send和Sync的,编译阶段会自动给这个类型实现这俩trait。
如果我们的类型不是Sync和Send的并且我们想要给这个类型实现Sync和Send这两个trait,那么这个时候我们就得用unsafe包裹,因为编译器无法确定你这个类型是可以安全发送或者安全接收的。
Union的字段(field)union和struct很像,但在特定的实例中一次只能有一个字段被使用。
它主要是用来和C语言中的union对接。访问一个union是不安全的,因为rust无法保证访问的时候这个数据是否还在这个union上。
可以看这个文档(俩文档长得可真像,差点以为漏了知识点)
The Rust Referencedoc.rust-lang.org/reference/items/unions.html
unsafe你觉得需要用的时候就用。。。
如果出了问题,可以通过unsafe关键字快速定位。
trait之前我们学过trait了,现在我们来深入了解下。
associated types)的trait中指定占位类型(placeholder types)占位类型和关联类型是相关联的,它能被用在method中。
而在实现trait的过程中需要开发者去实现关联类型来替代这个占位类型。
为什么要这么做呢?因为这个trait会被什么类型实现我们是不知道的,而编译器编译又需要确定具体类型,这个时候就需要占位类型了。
通过定义占位类型为关联类型,直到某个类型实现这个trait的时候声明具体关联类型替代这个占位类型,这个占位类型相当于关联类型的初始值。
之前我们就接触过了关联类型了,比如迭代器那一章节,我们需要实现Iterator这个trait,它要求我们实现关联类型。
我们在实现Iterator的时候需要定义type和next这个method。
这个type Item就是关联类型,这个Item就是占位类型。
看起来关联类型和泛型挺像,其实还是有区别的。
我们来用泛型改写这个trait
定义的效果是一样的。
但是在实现或者调用的时候就有区别了。
当我们的trait中存在泛型参数的时候,它可能会为同一个类型实现多次。
比如将这个count类型改成T之后它可能是 i32、u32、f64等,每一次都会把这个泛型参数替换成具体的类型参数。 并且当我们调用方法的时候,我们可能需要手动提供类型,因为我们给这个类型实现了多个trait,它需要指定确定的类型这样才能达到预期。
而对于使用关联类型的方式来说,一个类型它只能实现一次这个trait,我们在方法中就不需要再提供具体的类型了,它直接使用这个关联类型。
我们来实现这个trait
我们指定了关联类型是u32,占位类型Item到时就会被替换成u32。
default generic type parameters)和操作符重载(operator overloading)当我们使用泛型类型参数时,我们可以通过传入一个具体的类型作为默认类型。这样在实现这个trait的时候如果命中默认类型就不需要写那么多代码了。
规范是``, 左边是占位类型,也就是T,右边则是默认类型。
这也就是说我们可以搭配泛型和关联类型来解决关联类型只能实现一个以及泛型需要声明变量类型这两个问题。
除了能减少代码之外,默认泛型类型参数对操作符重载(operator overloading)这个场景来说是非常有用的。
操作符重载,顾名思义就是覆盖原来的操作符功能,比如覆盖+符号的功能来自定义具体逻辑。
rust是不允许我们去修改操作符的功能的,但是我们可以通过重载std::ops里的trait来达到自定义操作符功能的效果。
直接来看个例子
正常情况下两个struct相加是会报错的,但是现在我们重载了+操作符,允许两个struct相加。
而对于Add这个trait来说
可以看到它有一个关联类型type Output,还有一个Self作为泛型类型参数Rhs的默认值。
因为这个默认类型参数的存在,我们在实现这个Add的trait的时候并不需要去指定泛型的类型。
我们再来看个例子,这回我们不使用默认类型。
这回就不是两个相同类型相加了,而是毫米和千米相加。
Millimeters和Meters都是元组结构体,这种在别的结构体中轻包装(thin wrapping)的类型通常被称为新类型模式(newtype pattern),我们之后会接触到。
我们通过给Add泛型传Meters具体类型来实现这个效果,即我们给Millimeters实现了这个Add的trait。
默认类型参数一般用在以下两个场景中:
fully qualified syntax):分清多个同名method有这么一个场景我们之前都没遇到过的,一个类型自身有一个方法比如add,然后这个类型实现了两个trait,这俩trait自身都有一个add方法。
这个时候rust并不会报错。
我们来看下例子
我们这个Human的struct自身就有一个fly,然后它实现的Pilot和Wizard各自都有一个fly的方法。
然后我们来运行输出下,看是哪个fly被调用了。
可以看到是Human自身的fly,当然,按照逻辑来说未指定的情况下也应该是自身的fly。
那么要怎么调用另外俩fly呢?
你这里应该注意到了我们的fly是method,第一个参数是&self,那么我们能不能通过这个&self来判断呢?很遗憾并不能,但是我们可以利用这个self。 我们在前端中可以通过类似把this传入到具体上下文中再通过执行各自的方法,说的通俗一点就是数据共用一份。
有那么一丁点工厂模式和单例模式的区别。
rust中也有类似的
现在我们可以区分开来了
但是这里还有个问题,如果这个方法是一个关联函数呢?这个时候就没有self了。
虽然不是关联函数,但是同样没有self参数。
这个时候我们就无法使用上面的方式了。
该轮到全限定语法了。
写法有些类似typescript中给某个编译器不确定的类型限定为具体的一个类型。
规范是::function(receiver_if_method, next_arg, ...)。
这样就正常了。
这个语法同样适用于method。
最开始的例子可以改成这样的写法。
同样是没问题的,就是有些蛋疼。
supertraits来间接使用trait的功能简单的说就是你定义的trait还依赖另一个trait,那么这就是个supertrait。
我们直接来看下例子
和trait bound类似,如果我们的trait需要使用其它trait的方法,我们需要在名字后面: trait。
现在我们的OutlinePrint就可以使用to_string这个method了。
我们来运行下
我们的struct没法被formatted。
因为我们的struct并没有实现这个Display的trait。
来改下代码
现在应该没问题了。
至于为什么这里还需要给Point实现这个Display的trait,那是因为trait bound,你要实现这个trait,那你就得先实现Display这个trait。
newtype pattern)来在外部类型(external type)中实现外部的trait(external trait)之前学trait的时候我们有学到*orphan rule* 也就是孤儿规则,你要实现这个trait,那么你的类型和trait都得是本地的。
这样虽然不错,但是很麻烦。
而新模型模式就可以帮助我们绕过这个问题。
还记得元组结构体么?比如
对于这个i32来说,这个Tu是一个轻包装(thin wrapper)。
如果这个i32你想给它实现一个trait,它是一个外部type,那么就可能受限于这个孤儿规则。
但是现在你把trait实现给这个wrapper也就是Tu,那就不同了,因为这个Tu是一个local的struct,类型互相包裹可没有孤儿规则。
newtype pattern也就是新类型模式是一个来自Haskell编程语言的术语。
另外不用担心有runtime损耗,这个套娃的类型会在编译阶段被处理成常规的类型。
我们直接来看个例子
我们先不用这个wrapper,准备给Vec实现一个Display的trait,这俩都不是我们这个屯的。
这个毫无疑问是会报错的
接着我们来用newtype pattern
这回就没问题了。
不过这么做也是有缺点的,这个wrapper没有你这个包裹的类型的方法,它只有实现的这个trait的方法。
不过这个问题也挺好解决的,直接把这个包裹的类型暴露引用出去即可。
这么做稍微会有些繁琐和危险吧,毕竟数据是直接暴露出去的。
不过更好的应该是实现Deref和Deref_mut这两个trait,这样调用的时候可以通过解引用来改变内部的数据或者访问。
你也可以把这个类型实现的trait都同步实现给这个wrapper,这样wrapper就可以像一个vec来使用了并且这个类型数据就不用暴露出去了,就是比较低效。
Advanced types)我们之前接触过一些类型系统的特性,但是并没有去了解它们。这里也不准备全部都过一遍,就了解几个比较常用的。
上一个章节最后我们就用到了新类型模式。
我们用一个wrapper包裹我们的类型,然后帮它实现某个外部trait来绕过孤儿规则。
其实这相当于一个封装。既然是封装,那就可以对这个类型做保护操作,比如一些方法私有化等。另外还能省略内部实现过程。
举个栗子,你可以用一个people的类型wrap一个HashMap,这个hashmap包含着这个人的id也就是身份证,这个是不可对外暴露的,这个时候就可以提供限制调用的入口来达到保护个人隐私的作用。
type aliases)使用别名这个相信各位前端大佬都很熟悉了,可以方便我们写代码,抽取重复的类型等。
我们直接上例子
这就是一个别名,把i32别名为Kilometers类型。
写法和typescript中的挺像的。
当我们的限制的类型太多并且有多处调用的时候就可以用类型别名把它抽离出来。
比如:
这个Box 有三处地方用到了,有多又长,这个时候用类型别名把它抽出来就很舒服。
整洁了很多。
不过这类型名字尽量要和类型贴边,因为你在别处使用这个类型时其它开发者是不清楚什么意思的,这里面又一层抽象理解的成本。
类型别名比较常用的场景是在Result中。
而Result在I/O场景中用的多,而I/O的错误类型是std::io::Error,表示任何可能的I/O错误情况。
比如:
看到这代码,有洁癖的老哥早就忍不住了。
但是,这里有个陷阱,那就是Result的T是不一样的,有空元组和usize。但是E是相同的,还是可以抽的。
这个时候你应该想到了泛型,我们把T抽出来可不就行了?
这回舒服了~
注意别名仅仅只是别名,对于功能完全没有影响。
Never类型rust中有一种特殊的类型!也就是空类型,不过在rust中叫它never。
直接来看个例子
这样表示这个函数永远不会返回值。
那么它一般用在哪里呢?
我们再来看个例子
之前我们实现的猜数游戏,在循环中等待用户输入正常的数据,当用户的数据转换成u32失败的时候就会直接跳过不返回值,那就说明这是个never。
而之前我们最开始使用expect,失败直接panic,后面改为了这个match + continue。当时没有解释这个点。
而如果不使用这个never的话,这个match基本上就无法过编译,除非你Err的arm返回u32数据。
而对于never来说,由于不会返回值,那这里就只有一种情况会返回值,那就是Ok的时候,所以这个match一定只会获得一个类型,这就符合强类型了。
另外,根据上面的解释,panic!也是一种never。
不过需要注意不是所有能打断流程的都是never,比如break和return都不是never,因为它们都可以返回值。
trait通过前面学过的知识,我们知道rust编译阶段就需要确定类型的大小,这样好分配空间。
比如之前说过的,一个enum类型会选择它里面最大的那个的空间作为这个enum的固定空间来分配,因为一次只会有一个变体。
但是实际上,rust还存在一些无法计算大小的,比如套娃类型树结构,因为理论上可以无限套娃,所以改成指针存储放到runtime再去分配。
而今天我们学的这个叫动态大小类型(dynamically sized type)。顾名思义,这个类型的大小是会动态改变的。一般缩写为(DSTs)或者unsize types。它们实际上也是只能去到runtime才能确定大小并分配空间。
str(注意不是&str)类型是其中一种动态大小类型。由于大小只能在runtime阶段才能知道,所以我们并不能直接使用,因为编译器会报错。
来看个例子
那么上面的代码要如何改呢?
其实直接改成&str就好啦~,和之前一样,既然大小动态,那我们就拿它的指针,反正指针大小固定。
&str这个引用带有两个信息,一个自然是这个切片的内存地址的起始位置,另一个则是这个切片的长度。
当然,你也可以用之前学过的Box或者Rc包裹这个str类型。
另外你是否还记得trait objects呢?它们的规范是Box(Rc也可以),它也是runtime时候才能知道大小和分配,所以它也是一个动态大小类型。
rust提供了Sized这个trait,它可以用来trait bound泛型是否是动态大小。
实际上这个trait是会自动给所有可知大小类型实现的,并且隐式的把这个bound到每一个泛型中。
比如:
实际上会变成类似下面这种:
但如果你这个trait涉及到了动态大小的类型,那么你可以改成这样:
这样表示这个T可能是已知大小的或者未知的。
另外我们把参数的T改成了&T,因为这个如果是动态的,那就只能是传指针的了。
这里叫高阶函数/闭包会不会熟悉一点。
我们之前已经接触过把闭包传给函数了,比如unwrap_or_else。其实函数也能传给函数。
通俗的说就是把函数作为参数传给另一个函数。当一个函数作为另一个函数的参数时,它的类型在另一个函数中实际上是fn,也就是函数指针。
另外参数名一般叫f。
用法上和传闭包类似,直接来看个例子。
我们把add_one作为参数传给了do_twice,然后do_twice调用两次add_one,然后返回两次add_one相加的值。
和闭包不同的是,这里把fn直接作为一个类型而不是trait,这样我们可以直接把这个fn作为参数直接使用而不是搞一个泛型参数,然后trait bound这个fn。
之前我们学闭包的时候有说过,闭包的三个trait(Fn, FnMut, FnOnce)函数同样实现了。
所以如果一个闭包是一个函数的参数的话,实际上我们可以传入一个函数来代替闭包。你这个函数的参数得用泛型 + 上面三个其中一个来trait bound。这样你的函数就能接收函数和闭包了。
来看个例子
map是迭代器的一个方法,我们之前用过,它接收一个闭包,闭包的参数是迭代的项,然后要求闭包返回值。
其实我们也可以直接传一个函数。
这样也是可以不会报错的。
ToString这个trait是一个标准库提供的trait,prelude的,它要求实现这个trait的都得先实现Display这个trait。而标准库中实现Display的trait也都实现了这个ToString。
当然,有的时候我们并不想两个都可以接收,我们只想接收函数,比如在和C对接的时候,对方压根没有闭包这玩意儿。
另外枚举类型的变体实际上也能做为函数,也可以作为函数指针传入另一个函数中。
这个Status::Value是一个变体,也可以变成一个初始化函数。
我们并不能直接返回一个闭包,因为闭包由trait表示(原话是Closures are represented by traits)。
大多数情况下,如果你想返回一个trait,你可以使用一个具体类型实现这个trait作为函数返回值。这种方式并不适用于闭包,毕竟闭包是一个匿名函数,无法抽象出一个具体的类型。
来看个例子
看这报错是因为无法确认需要的空间,那这好办,我们用Box给它包裹下
这回就正常了
现在这个返回值变成trait object了。
macros)宏我们已经接触过了,比如println!、panic!、vec!等。但是我们并没有了解过它的原理。
在rust中,宏有两种。
declarative macros也就是声明宏,关键字macro_rules!procedural macros也就是过程宏,它有以下三类#[derive])宏,自定义派生的属性,作用于structs和enum类型。Attribute-like)宏,要不就叫属性宏算了。。可自定义属性作用于任何的项中。Function-like)宏,这个就叫函数宏算了。。看起来像是函数调用但是操作将它作为参数的tokens。从底层来说,宏是一种写代码但是会生成另一堆代码的方式,一般也被叫做:metaprogramming 也就是元编程。
它们会被展开为更多的代码,有些类似迭代器的代码会被展开而不是循环处理。
你可以基于元编程来减少很多代码,这样可以提升代码的简洁已经维护性。
当然,函数也可以实现。但是,宏有函数没有的额外功能。
函数声明必须要声明参数的个数和类型,而宏并不需要确定参数的个数,比如我们使用println!
我们可以传入一个参数,两个参数都可以,不会报错。并且宏会在编译器解析代码之前展开。
所以宏可以做挺多东西,比如可以给一个类型实现一个trait,而这是函数无法做到的,因为函数的调用是在runtime,而trait的实现是在编译阶段。
这有一个使用宏的缺点,那就是宏的实现比函数的声明复杂很多,因为它会转换成rust代码(原话:because you’re writing Rust code that writes Rust code),我个人的理解是这之间有一层转换成抽象,又从抽象转换会代码的过程。
可以类比通过babel把js代码parse成AST然后又通过transform和generate再转换回js代码。
因为这一点,宏的定义可读性极差,并且可维护性也比函数差很多。
另外一个重要的不同点就是在你使用宏之前你需要把宏引入当前上下文环境中。
而函数则不同,可以定义在任何地方并且可以在任何地方调用(真的假的)。
macro_rules!在常规元编程(general metaprogramming)中声明宏(declarative macros)rust中使用范围最广的宏就是声明宏。所以有时候我们说的宏指的就是声明宏。
简单的说,声明宏可以让我们写一些类似match表达式的代码,我们之前学的match表达式是一种控制流表达式。对比表达式的值和模式(arm),然后执行匹配到的模式代码。
宏也是比较值和模式(也就是相关的代码),在这个场景中,值是字面上的rust源码传递给宏的。
那么模式就会和这段源码的结构进行匹配,和模式匹配上的代码会被替换。
这些过程都是在编译阶段完成的。
如果要定义一个声明宏,我们就需要用到macro_rules!这个宏。
我们来看下简化的vec!是如何实现的,它也是一个声明宏。
这个功能并不能用函数来实现,因为我们并不清楚参数的个数。
直接来看下简化的vec!代码,注意标准库中真实的vec!是提前分配空间的,并且是最优的。
第一眼压根看不懂。
我们一步一步来拆解
#[macro_export]注释表示这个宏所在的crate被引入后这个宏都会是有效可用的。
没有这个注释,这个宏将不会跟着进入作用域。
然后我们使用macro_rules!定义这个宏,然后我们的宏名字不需要带上!符号。
然后具体内容使用{}包裹。
所以规范是macro_rules! name { ... }。
接着在大括号内我们有一个arm: ( $( $x:exper ), * )然后再接=> { ... }
看着也确实和match表达式很像。这个宏里面只有一个arm,实际上复杂的情况下会存在多个arm。
由于宏匹配的是代码的结构,所以模式匹配的语法在这里是不适用的,宏有自己的模式匹配语法。
具体可以看:https://doc.rust-lang.org/reference/macros-by-example.html
回到我们的arm中,我们用()小括号包含我们的模式,然后使用$符号申明了一个变量,这个变量包含的值将会是匹配到的源码。
然后我们又用圆括号包裹$内的内容,里面的内容则是匹配到的值,这些值将会被用于替换。
$x:expr,expr自然是表达式的意思,而$x则是声明变量x,连起来也就是把匹配到的rust表达式赋值给x这个变量。
圆括号后面接了一个,,没什么意思,就是字面上的,,因为你这个vec可能有多个值,那这些值自然就是根据,分隔。
接着又接上了一个*符号,看到这相信各位应该猜到这个arm的写法和正则表达式有关联了。这个*符号自然表示匹配零个或者多个。毕竟可能存在多个元素。
回到我们的调用中vec![1, 2, 3],那么这个表达式中$x将会匹配三次表达式,分别是1, 2和3。
接着{}里的内容第一行先是创建一个空的vector。
然后又是$()*这种写法,*还是老样子,零次或多次。那么()括号内的代码则会执行零次或多次。自然是根据匹配到表达式个数来执行n次。
最后就是返回这个vector。
上面的代码会被拓展开来,实际上就是类似下面这种写法的代码
现在相信大家都挺清楚宏的原理了,不过实际上是更复杂的。
之前我把这个宏说成是语法糖这么看也是挺有道理的。
我点到为止(好自为之),就不深入了,因为平时基本不会去写宏。
而作为大佬的你,完全可以跟着下面这本书来实现一个属于自己的宏。
The Little Book of Rust Macrosveykril.github.io/tlborm/
procedural macros)通过属性来生成代码比起声明宏来说,过程宏会更像函数。
和声明宏不同的是,过程宏接收一段代码,操作这段代码并输出处理后的代码,没有模式匹配这一过程。
还记得上面提到的三种过程宏类型吗?custom derive、attribute-like、function-like。它们之间挺像的。
注意,过程宏的定义一定要在当前的crate里面,并且带有特殊的crate类型。
至于为啥要这样,那是因为一个复杂的技术性原因,rust开发团队希望能在将来消除这个问题。
直接来看下例子
它接收TokenStream类型的参数,输出TokenStream类型的值。
这个TokenStream类型由proc_macro提供,它包含在rust中,代表一段序列化tokens。
然后我们用some_attribute属性标记这个宏。
同一个crate中允许存在多种过程宏。
custom derive macro)我们准备来搞一个自动给struct实现trait的派生宏。通过derive也就是派生的属性来判断。
之前我们也接触过派生的属性,比如Debug。
假设我们不用这个宏来实现,那么我们就得先实现一个trait,然后手动给我们的目标struct实现这个trait,一俩个还好,多了就不礼貌了。
所以为了解决这个问题,我们用过程宏,通过属性来标识这个trait是需要实现这个trait的。
这样就舒服很多。
我们先来搞个工作空间(workspace),因为这里准备涉及到三个crate。
然后我们再来创建一个hello_macro的library。
然后配置下工作空间Cargo.toml
这个hello_macro的library放我们的trait。
然后my_pro包中引入这个trait
我们现在实现trait的方式是常规的方式,等会准备改成通过派生宏的来实现。
不过现在还是有问题的,我们并没有在my_pro中引入hello_macro这个包,我们来修改下my_pro/Cargo.toml
现在运行这个my_pro应该是没有问题了,我们来试下
正常,我们接着往下。
我们来改下hello_macro_derive这个包的Cargo.toml
proc-macro声明这是个过程宏的crate,然后我们还需要借助syn和quote这两个crate的功能。
接着,我们来实现这个过程
hello_macro_derive/src/lib.rs中
我们来解释下这一大段代码
先直接看代码内容,我们把接收进来的input parse成抽象语法树(AST),然后传入impl_hello_macro这个方法中。
然后impl_hello_macro这个方法中获取这个trait的ident也就是这个trait的名字。
然后再把这段代码通过quote!自定义了实现一段代码(可以理解为template),然后我们把name替换到#name中,然后这个宏转换回TokenStream类型。
最后调用这个TokenStream类型的into方法把代码转换成编译器可以分析的代码。
然后回过头来分析下
proc_macro这个api是自带的,所以我们并不需要去引入。
然后我们通过#[proc_macro_derive(HelloMacro)]注释proc_macro_derive这个方法,它会在#[derive(HelloMacro)]被识别到的时候被调用。
另外这个属性尽量保持和trait名字一样,这样就不会有误解。
这个抽象语法树大概长这样
ident自然就是我们的目标struct的名字。
对这块感兴趣的可以看这个文档
https://docs.rs/syn/1.0/syn/struct.DeriveInput.htmldocs.rs/syn/1.0/syn/struct.DeriveInput.html
quote!这个宏允许我们去自定义想要返回的代码,同样感兴趣的可以看这个文档
https://docs.rs/quotedocs.rs/quote
stringify!这个宏会把表达式转换为字符串,比如1 + 2会变成"1 + 2"而不是变成3
简单的说,syn::parse相当于babel的parse将js代码转换成抽象语法树,
然后quote!相当于babel的 transform,可以让我们操作源码。
最后再generate转换回源码
现在我们已经知道完成这个宏了,我们来使用下。
my_pro/src/main.rs中引入
正常输出~
attribute-like macro)和派生宏类似,不过不需要派生,也就是derive, 咱可以直接搞一个新属性了。
而且派生宏只能作用于struct和enum,而属性宏则可以作用于更多的项上。
比如
这表示这个函数是一个路由
而它对应的注释得这么写
proc_macro_attrbute表示这是一个属性宏。
没了~其他基本和派生宏差不多。
function-like macro)顾名思义,就是看起来像函数调用,不过它能做的事比函数多得多,比如参数个数并不需要知道。
它的参数和上面两个类型一样,也是TokenStream。
来看个例子
sql这玩意儿没想到在这里看到了
而它的实现类似这样
固定proc_macro,没有属性。
这一大章,我们学了unsafe、高级trait、高级类型、高阶函数/闭包以及宏。
实际上我们只是浅浅的了解了一下,要深入的话估计得好久。
有种如履薄冰的感觉,前面学的什么都快忘了。。。
突击检查1:什么是孤岛规则
突击检查2:闭包中涉及到的三个trait
突击检查3:生命周期省略注释的三把斧
发布于 2023-01-11 20:31・IP 属地广东