分类 精读 下的文章

久别重逢,最近换了新工作,三个月很忙碌,以后博客会定期更新;前几天Vue发布了3.3,我今年没写过Vue,但是也关注Vue的进展,总的来说Vue的发展方向在version 3这个版本中基本稳定了,反而DX的设计上会越来越好,如果你不使用Typescript,那么Vue3的许多新特性你可能并不适用,今天我们简单看一下Vue3.3的内容。

概览 (GPT总结)

  • defineOptions:可以在 setup 中使用它来声明组件名称等组件属性,避免了需要再写一个script标签,使得新手和 Vue2 -> Vue3 的用户更容易理解。
  • setup 中的 TypeScript 类型改进:支持从其他类型文件中导入类型,不再需要在 内使用 defineProps 等宏定义类型,但是目前还不支持复杂的类型操作。
  • defineModel:简化了父组件和子组件双向通信(v-model)的代码,使得双向绑定的逻辑更加明了。
  • defineSlots:可以自定义组件的 slot 类型,适用于在复杂场景下 Volar 不能准确推导组件类型的情况。
  • 泛型组件:对于带有 slot 的复杂组件来说,可以自动推断传入的类型。
  • setup 中的提升变量:支持将基础数据类型(除了 Symbol)的变量提升到顶部,解决了之前在 defineProps 时只能使用字面量的问题。

defineOptions

在之前的setup语法糖里面,如果要声明组件名称等组件属性,需要再写一个script标签,这会给新手和Vue2 -> Vue3的用户带来困惑,并且会给Eslint/Volar带来一定的难度,现在可以使用defineOptions宏来指定这样的信息,但是defineOptions不允许指定props/emits,因为这两者可以使用其他宏指定。

<script setup lang="ts">
defineOptions({
  name: 'HelloWorld',
})
</script>

setup中的Typescript类型改进

这是一个很旧的问题,在使用defineProps时,是不能从其他地方引入类型使用的,可以具体查看RFC。在这个版本将支持从其他类型文件中导入,比如这样:

<script setup lang="ts">
import type { Props } from './foo'

defineProps<Props & { extraProp?: string }>()
</script>

只不过目前并不支持复杂的类型操作,在之前我们使用defineProps做类型声明时,如果传入了类型,编译器将会将类型转换为运行时代码;而现在如果要支持外部的类型要么就调用Typescript的龟速编译器,要么就自己实现一个基础的编译器,让其识别到导入的“是什么类型”。目前Vue采用了第二种方案,并且支持一个复杂类型的例子(如上代码所示)。

defineModel

简化了父组件和子组件双向通信(v-model)的代码,比如在以前我们编写v-model相关逻辑,需要写这么多代码:

<script setup lang="ts">
const props = defineProps<{
  modelValue: number
}>()

const emit = defineEmits<{
  (evt: 'update:modelValue', value: number): void
}>()

emit('update:modelValue', props.modelValue + 1)
</script>

现在使用defineModel:

<script setup>
const modelValue = defineModel<number>()
modelValue.value++
</script>

defineSlots

在复杂场景下,有时候Volar并不能准确推导组件类型,或者有时你想自定义组件的slot类型,可以使用defineSlots:

defineSlots<{
  default(props: { item: T }): any
}>()
<HelloWorld :data="['foo', 'bar']">
  <template #default="{ item }">{{ item }}</template>
</HelloWorld>

泛型组件

这对于普通组件来说意义不大,但是对于带有slot的复杂组件来说很有用,意味着可以自动推断传入的类型,在组件中使用,比如以下的场景:

<script setup lang="ts" generic="T extends string | number, U extends Item">
import type { Item } from './types'
defineProps<{
  id: T
  list: U[]
}>()
</script>

T和U都可以被用作emit/ts代码逻辑中,它和defineSlot在一起用最好

scripts中的提升变量

Vue3在之前在编译sfc的template时,会将静态内容提升,这样在大型项目中重复的静态内容会用这样的优化手段会得到提升,可以看一下之前的Vue文章。但是现在在setup中,也支持这样的优化了,如果在script中编写了基础数据类型(除了Symbol)的变量,都会被提升到顶部。这个改进主要解决了之前的问题,比如defineProps时,只能使用字面量:

<script setup>
const hello = 'world'
defineOptions({
  hello,
})
</script>

这样的代码在之前是会有编译错误的,现在不会了,原因是defineProps宏编译之后,会变成下面的代码:

const __sfc__ = {
  props: [propName],
  setup(__props) {
    
  },
}

显然这是错误的,无法访问到propName,经过提升优化之后,将会解决这个问题:

const __sfc__ = {
  propName: "hello",
  props: [this.propName],
  setup(__props) {
    
  },
}

(伪代码)

更符合人体工学 的 defineEmits

同样的,这次改进主要是少写一些代码:

const emit = defineEmits<{
  (e: 'foo', id: number): void
  (e: 'bar', name: string, ...rest: any[]): void
}>()

现在可以这么写:

const emit = defineEmits<{
  foo: [id: number]
  bar: [name: string, ...rest: any[]]
}>()

解构Props不失去其响应式(实验性)

如下代码所示,这更好的提供props的默认值:

const { msg = 'hello' } = defineProps(['msg'])

此时,msg仍然还是一个响应式变量,并且如下代码可能也不会再需要了:

export interface Props {
  msg?: string
  labels?: string[]
}

const props = withDefaults(defineProps<Props>(), {
  msg: 'hello',
  labels: () => ['one', 'two']
})

在此前经常使用withDefaults宏去提供props默认值,现在有更好的趋近于es6的写法,所以建议大家之后可以使用这个功能,但是这个功能是实验性质的,需要自行斟酌。

概念

HTTP是超文本传输协议, 一个词里面有3个关键信息

  • 超文本
  • 传输
  • 协议

那么超文本的意思就是字面含义, 就是不止是文本, 也有可能是图片,音乐, 视频等等, 而传输协议指的就是在计算机层面之上的一种交流约定方式, 那么我们可以理解为这个HTTP可以允许我们在计算机上实现双向的传输交流的规范

Methods

每个http都有自己的method, 每个method对应的操作虽然是开发人员自定义实现的, 但是我们在编写操作逻辑时, 也要尽量遵守http method的语义标准

  • GET和POST的区别
首先根据RFC的定义, GET是从服务端获取一个资源; 而POST是根据报文内容对资源去做对应的处理
  • GET和POST是安全的么, 它们都是幂等的吗?
首先根据HTTP定义的安全标准, 指的就是是否在服务端操作了数据, 一旦操作了数据, 其实这个请求就是不安全的; 幂等指的就是多次提交和一次提交的影响都是一样的, 显然GET只是获取服务器资源, 它是安全且幂等的; 反之, POST多次提交不一定和一次提交的影响是一样的, 所以POST是不安全且不幂等的

缓存

在HTTP中缓存分为2种

  • 协商缓存
  • 强制缓存

当浏览器向服务端第一次请求资源时, 浏览器通常会缓存数据, 那么在下一次请求时如果资源还没过期, 将会使用缓存中的数据, 那么这就是强制缓存, 通常会在HTTP中显示(from disk cache), 强制缓存主要取决于客户端

而取决于服务端的HTTP就是协商缓存, 在协商缓存中的服务端会返回给客户端不同的响应头, 并且状态码是304, 即告知浏览器可以使用缓存, 大名鼎鼎的Vite在预构建依赖时, 就用了这种缓存方法优化了HTTP性能.

我们在学习协商缓存的时候, 也需要知道最后修改时间实现缓存的, 还是基于ETAG实现缓存的; 我们在业务使用中, 会选择ETAG实现 (并且浏览器机制下ETAG优先级更高), 因为ETAG解决了时间实现的几个缺陷:

  • 时间只能精确到秒, 有时候修改文件是在秒之内, 所以最后修改时间存在误差
  • 服务器可能获取不到最新的时间
  • 我们关注的是文件内容本身, 虽然本身内容没有被修改, 但是最后修改时间可能会改变

ETAG: 唯一资源标识符: 当第一次请求服务端会返回一个唯一标识, 客户端请求携带, 由服务端告知客户端是返回200 (被变更, 返回最新资源), 还是304(没有变更)

协商缓存和强制缓存需要搭配使用, 当强制缓存未命中时, 再使用协商缓存

HTTP版本演变

我们在谈论HTTP版本时, 通常会谈论HTTP1.1和2.0以及3.0

首先, HTTP1.1相比HTTP1.0有哪些重要改进呢?

  • 使用长连接改进了1.0时的短连接
  • 支持pipeline网络传输, 不需要等待前一个请求返回再发出第二个

但是尽管改进了很多, 但是HTTP1.1还是有很多不足

  • header不压缩, 只压缩了body部分
  • 队头阻塞, 虽然可以发出请求可以不用等待之前的返回, 但是HTTP1.1是按照请求的顺序返回的, 如果前一个请求响应慢, 会造成之后的请求返回阻塞
  • 发送了冗长的首部, 造成浪费
  • 只能客户端主动发起请求

为了解决这些不足以及安全性问题, HTTP2.0诞生了, 解决了如下的痛点问题

  • 发送二进制格式
  • 头部压缩
  • 并发传输, 解决了响应阻塞问题
  • 服务器主动推送

头部压缩

头部压缩, 如果你同时发出了多个请求, 那么它们的头部都是一样的或者是相似的, 那么HTTP内部会使用HPACK算法对其进行处理, 大概的原理就是客户端和服务端共同维护一个表, 在表中存储各种的头信息, 每次没有必要发送冗长的头部, 只需要发送索引号即可.

二进制传输

二进制传输格式也大大提高了传输效率, 它们被称之为帧(Frame), 分为头部帧和数据帧, 在原来的HTTP版本中, 是明文传输的, 但是在HTTP2.0中直接使用了二进制, 无需将明文转换为二进制

并发原理

那么HTTP2.0是如何实现并发传输的呢? 原理也很简单, HTTP2.0中引入了Stream的概念, 在一个TCP连接中可以有多个Stream, 每个Stream有不同的id, 在其中也会有不同的request和response的请求和响应, 那么再里面就是我们熟悉的, 由于Stream之间都是独立的, 所以可以乱序发送, 然后在客户端会根据id拼接HTTP消息, 这就是HTTP2.0并发传输的原理.

服务端推送

服务端推送也和Stream理念不可分割, 因为在1.0和1.1都是请求应答传统模式, 即客户端请求, 服务端应答, 在HTTP2中对这个模式进行了优化. 在客户端和服务端分别建立Stream, 它们的id略有不同, 客户端是奇数, 而服务端是偶数. 那么服务端推送有什么好处呢? 最大的好处就是减少网络次数往返, 比如客户端接受一个html文件, 那么在html中又引入了css,js等文件, 那么我们就可以用服务端推送html中的css和js文件, 减少了消息传递次数.

HTTP3

看似HTTP2很完美了, 该有的都有了, 但是我们仔细想一下, HTTP2解决的队头阻塞看样子是通过Stream完美解决了, 但是HTTP2是基于TCP的, TCP是字节流协议, 它最大的特点就是要保证数据是连续并且完整的, 它有一个缓冲区的概念, 当前一个请求缺少字节的时候, 只能将这部分数据保存到缓冲区中, 等字节达到之后, 才可以被HTTP这个应用层获取到数据. 也就是说, 我们虽然解决了HTTP队头阻塞, 但是并没有解决TCP的队头阻塞, 所以要想完美的解决这个问题, 我们只能使用UDP

那么, TCP是可靠传输, UDP是不可靠传输, HTTP3是如何底层使用UDP的呢, 原因就是使用了UDP为底层的QUIC协议, 它完美解决了队头阻塞, 即就算某个Stream丢包, 也不会影响其他Stream 而且在QUIC协议中, 对建立连接做了很多优化, 比如说在HTTP2之前与TLS握手, 都要建立三次; 但是在QUIC中, 因为其包含了TLS, 并且使用TLS的更高版本, 可以在HTTP的三次握手中进行TLS信息的携带, 所以可以说把TLS的握手过程隐藏了.

那么为什么HTTP2不把TLS进行囊括合并呢? 是因为TCP是内核实现的传输层, TLS是openssl实现的表示层, 无法合并

HTTPS

HTTPS就是在TLS/SSL基础上的一个应用, 我们在HTTP中发送的数据都是明文的, 这并不安全会带来很多隐患; 通过SSL校验服务端身份, 并且在通信过程中间加密; 那么它们的建立连接的过程可以分为以下几步

  • 发起HTTPS请求
  • 服务端返回SSL证书的public key
  • 客户端自己生成一个对称加密的public key
  • 拿SSL的public key加密刚刚生成的public key, 并且发送
  • 之后客户端发送的消息都会拿刚刚加密过后的public key对数据进行加密

区别

  • SSL证书要钱, 但是大部分是免费的
  • 多一个TSL握手的过程
  • 一个明文, 一个是密文
  • HTTP默认端口是80, HTTPS是443

OSI七层模型

OSI将网络体系划分为7层, 7层互不干扰, 每一层互相独立协议, 并且独立完成和相邻接口通信功能

应用层

我们熟悉的DNS, HTTP, SMTP都是应用层

表示层

主要作用就是让数据能够解释和交换

会话层

建立, 管理会话

传输层

TCP/UDP

网络层

IP

数据链路层

令牌环网

物理层

以太网

TCP/IP4层模型

TCP模型比OSI模型更为抽象, 它将应用, 会话, 表示都称之为应用层, 将数据链路层和物理层称之为网络接口层

DNS解析

简单说DNS就是一个翻译官, 把我们的域名翻译为IP地址, DNS查询的过程可以简述为

  • 查浏览器
  • 查操作系统
  • 查本地域名服务器
  • 查上游服务器
  • 给本地域名服务器缓存ip信息
  • 给操作系统缓存ip信息
  • 给浏览器缓存ip信息

TCP的三握四挥

三次握手的作用就是, 其实就是小明小红打电话

  • 小明: 小红听到了么 (客户端发送正常)
  • 小红: 我能听到的 (服务端发送和接收正常, 但是此时小红不确定小明的接收是不是正常的)
  • 小明: 那我开始说了 (此时2个端的发送和接收都是正常的)

四次挥手也是一样的, 也就是挂电话的逻辑

  • 小明: 我说完了, 你还有啥要说的么
  • 小红: 好吧, 我这里也没了
  • 小明: 那我挂了
  • 小红: 行, 你挂吧, 我也挂了

经典八股文: 浏览器地址输入到显示经历了哪些步骤

哪个前端面试没背过的, 都不配称之为前端.哈哈哈哈哈

我们简单过一下这一块, 虽然这里烂大街了

  • 首先就是对输入的内容进行解析
浏览器要判断你输入的是一个网址, 还是一个关键词
  • 如果是网址, 那就经过DNS解析拿到IP地址
DNS查询过程请看上方
  • 建立TCP连接
详见小明打电话的例子
  • 发送HTTP请求
  • 响应HTTP请求
对资源进行解析, 主要看content-type, 还有gzip等等, 需要对返回内容作处理
  • 渲染页面
解析和构造dom树 → 解析和构造css树 → 合并生成render树 → 布局layout树 → 绘制像素树 → 通知gpu进行绘制显示在屏幕上

课程介绍与编译技术概论

这个课程使用rescript进行学习, 主要的学习目标就是实现一个编程语言. 为什么要使用rescript去学习, 主要是因为rescript是元语言, 也是ocaml的一种方言; 这个语言并不是一种js类型加强 (例如typescript), 而是选取了一个js的子集进行重写改造, 有着优秀的语法设计并且可以编译出经过性能优化的js代码. 可以说, 它和ts属于2个赛道, 但是做的事情都是一样的, 即在开发中帮助程序员消除js的各种陷阱&添加类型.

为什么要学习编译器和解释器?

  • 有意义, 用自己的编程语言去写东西是一个很快乐的事情
  • 我们熟悉的编程软件, 比如js, 尽管可能需要了解js的全貌很难, 但是我们可以尝试去了解一个语言的最小实现, 对于学习语言底层会有更帮助一些; 其次我们可以了解每种语言的抽象机制, 即cost, 每个语言都有自己的擅长领域, 比如有些语言号称zero runtime cost, 但是它相对在编译中就会产生大量的cost, 比如js就是典型的需要在runtime中有大量cost的语言, 那么我们学习不同的抽象机制, 在遇到js相关疑难杂症, 会更容易我们调试, 比如经典的闭包内存泄漏等等…
  • 尝试写一些dsl, 比如vue的sfc就是典型的dsl (或者sql), 通过编译技术将模板编译为可供render的数据结构
  • 提升个人品味 (装逼)

编译管道

Untitled 1.png

  • 从一个字符串
  • 到抽象语法树
  • 经过类型检查到类型抽象语法树
  • 得到多层的IR (中间表示)
  • 线性化之后得到一个更低层次的IR
  • 代码生成得到机器码

在大家的印象中, 编译器的后端部分都比较重, 反而前端比较“简单”, 其实对于现代语言, 反而前端/中端更重要, 要做更多创新和类型检查, 反而后端变得可以重用了, 压力也很小, 因为可以用LLVM去处理IR, 生成的代码也很高效, 而且容易扩展

后端不“重”指的是现代大多数场景, 如果对于一些计算密集或者神经网络加速的, 会对后端要求比较高

Untitled.png

区别几个重要概念

  • 编译, 离线的, 在程序运行之前称之为(预)编译时
  • (解释)运行, 在线的, 每个程序最终都会有运行时. 包括c/c++, 只不过是在cpu执行的
  • 转译器, 从a到b, 它们之前是很相似的, 一般称之为转译

词法解析/语法解析 (前端部分 )

我们从一个简单的文本内容开始, 通过解析会得到一个抽象语法树, 这个是大致的流程; 在这个流程中, 会把文本内容拆解成一个又一个token, 然后会将一个个token再解析为一个语法句子( 比如空白字符, 注释等等…), 然后就得到了一个抽象语法树.

Untitled 2.png

语法对于语义影响很有限, 但是语法对于用户体验影响很大, 本质上我们只要确定了抽象语法树, 语法的设计只要符合规则即可

语义分析/解析

语义分析是在语法解析之后的步骤, 它依赖于ast, 目的很明确: 确定代码含义; 并且为每个语法带来更多的标注工作, 比如类型检查, 作用域, 在语义分析的步骤中, 可能会导致一些常见的错误

  • 类型不正确
  • 使用了未定义的变量

在导致一些错误之后, 也会给这个ast添加更多有用的信息, 为下一步处理做准备.

在这里需要提到一个专业名词叫做: type soundness ,指的就是类型系统中的一个性质, 表达了代码中如果存在类型, 那么在运行时就会严格按照类型运行, 不会出现任何错误, 实现了type soundness的语言也很多, 比如ocaml/rescript

我作为一个java黑, java一直以来都有争议说java是type safe, 但是它不属于type sound, 因为如果类型不一样, 它会直接抛出错误, 但是就算再差也比js好.

针对语言的优化

  • 模块
  • 面向对象
  • 模式匹配
  • class
  • 其他优化
  • 多层/单层的IR转译

线性的优化

cpu是一个流水线的架构, 只能理解线性的东西, 所以我们要有一个线性的IR, 这一层主要就是做IR优化, 比如

  • 常数折叠
  • 常数传播
  • …等等编译器优化技术

针对每个平台进行代码生成

首先每个平台的ISA(指令集架构) 都不一样, 那么这里最重要的一个优化点就是寄存器分配 ,这样做的目的很简单, 就是将我们常用到的变量都从栈上划分到寄存器上, 这样可以更快的执行, 可以更多的利用计算机资源.

因为寄存器在cpu之上, 由cpu直接控制, 所以比内存通信快, 但是它比内存容量要小, 所以要在程序的不同的地方进行寄存器共享, 这就需要编译器在编译期间进行寄存器分配

我们一般讲栈式虚拟机的时候, 是要把变量放在栈上的, 但是由于栈上的数据访问速度要低于寄存器, 所以我们需要把存储数据放到寄存器中即栈分配到寄存器分配, 这也是我们语言的一个优化; 对于编译器而言, 我们做了寄存器分配, 由于可以安全无冲突的共享数据, 也是编译性能的一个优化.

寄存器分配完成之后, 会做一些指令的调度, 针对不同机器相关优化, 比如说经典的后端优化技术peephole

它通过检查一小段指令序列来查找可以优化的机会, 达到更好的执行效率以及减少代码

之前提到过, 在绝大部分场景下, 编译器的后端技术其实并不需要太多的优化, 除非是神经网络涉及一些计算, 在我们熟知的go语言这一类的编译器, 其实后端并没做太多优化, 因为只要保证语言语义足够静态, 在编写编译器的时候, 又尽可能的用上优化手段, 其实都大差不差.

抽象语法 & 具体语法的区别

现在的编程语言解析过程中都不会牵扯到语义, 抽象语法可以一对多的反射到不同的具体语法的.

抽象语法指的是语言的理论结构, 指的是语句的形式和意义结构, 而具体语法就是我们实际编写的语法. 总之抽象语法是建立在语言基本规则之上, 是一种理论, 而具体语法只是一种用法和实现

比如说git就是内核实现了一套抽象语法, 有很多图形界面工具就实现了一个具体用法, 实际上操作的还是git的抽象语法

复合类型

顾名思义, 复合类型就是其他类型组合而成的, 最典型的就是结构体struct和枚举enum.

字符串

rust中的字符串和我们平时说了解到的编程语言不一样, 比如下面这一段代码是会编译错误的:

fn main() {
  let my_name = "Pascal";
  greet(my_name);
}

fn greet(name: String) {
  println!("Hello, {}!", name);
}

切片

切片在go中就已经很流行了, 它允许你引用集合部分内容, 而不是全部, 在字符串中, 我们可以这么写:

let s = String::from("hello world");

let hello = &s[0..5];
let world = &s[6..11];

如果是从0开始, 就可以省略0:

let hello = &s[..5];

同理, 如果你要截取到尾部, 你可以这样省略:

let hello = &s[1..];

但是如果我们操作中文, 就需要格外注意utf-8啦, 一个中文是3个字节, 所以我们在做切片的时候, 如果切到第二个, 就会编译器报错:

实际上, 如果返回切片, 其实是一个字符串引用, 所以我们可以写出这样一段代码:

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // error!

    println!("the first word is: {}", word);
}
fn first_word(s: &String) -> &str {
    &s[..1]
}

不仅仅字符串可以进行切片, 数组也是可以的:

let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);

如果我们通过字符串字面量创建字符串的话, 是这样的:

let a = "hello";

此时a就是&str类型, 我们完全可以这样编写代码, 因为此时a指向了可执行文件中的某个点, 也因此字面量创建的字符串是不可变的, 因为&str就是不可变的.

let a: &str = "hello";

字符串是什么

rust中的字符是由unicode编码实现的, 每一个字符占据了4bytes, 但是字符串是用utf-8实现的, 占用的字节数是1-4变化的, 所以更节省内存.

&str和String的区别

在语言层面上来说, 只有str一种类型, 是硬编码到可执行文件中的, 无法被修改; 但是在标准库中我们却可以使用String来创建一个不定长度的字符串类型; String同样也是utf-8编码也具有所有权特性;

如何互相转换

str → string

let a = String::from("hello world");
let b = "hello world".to_string();

string → str

fn main() {
    let s = String::from("hello,world!");
    say_hello(&s);
    say_hello(&s[..]);
    say_hello(s.as_str());
}

fn say_hello(s: &str) {
    println!("{}",s);
}

字符串索引

在js中, 我们可以使用索引轻松的访问字符串, 但是在rust中这是不被允许的:

let s1 = String::from("hello");
let h = s1[0];

因为字符串的底层是使用[u8]字符数组实现的, 如果我们在字符串中使用中文, 一般一个中文是3个byte, 那么此时我们操作中文字符串时, 可能会得到预想不到的值; 所以在rust中, 字符串索引是一个容易造成误解的功能, 因此不支持;

如何正确操作utf-8字符串

如果你想要通过unicode方式操作字符串, 就可以使用.chars()函数

for c in "中国人".chars() {
    println!("{}", c);
}

如果你想要查看字符串在底层的字节数组, 就可以使用bytes()函数

for b in "中国人".bytes() {
    println!("{}", b);
}

228
184
173
229
155
189
228
186
186

ps: 如果要在rust中截取正确的子串, 就要使用一些库, 比如utf8_slice

深入理解字符串

为什么string可变而str不可变呢? 这很容易理解因为str是在我们编译期间就可以知道的内容, 即不可变, 直接编译到可执行文件中; 但是我们在开发中, 不可能都使用不可变的str, 通常我们的值都会在运行时经过逻辑处理得到的, 所以我们需要一个可变的string类型; 可变的string类型是要在堆上划分一块区域存储的, 等到需要被释放时才会归还给操作系统, 整个周期都是在运行时;

元组

元组是由多种类型组合一起形成的, 它的长度是固定的, 顺序也是固定的; 我们可以通过这个函数创建一个元组:

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

可以用模式匹配来解构元组

let (x, y, z) = tup;

也可以通过.索引的方式来获取元组元素

tup.1

在函数中, 如果要返回多个值, 除了使用下面即将介绍的结构体之外, 还可以使用元组

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();
    (s, length)
}

结构体

在元组的介绍中, 返回的calculate_length函数是一个元组类型, 这对于程序来说是不好维护的, 因为不清楚返回参数的任何意义; 那么rust中有一个复合类型叫做结构体可以解决; 那么在其他语言中结构体可以当作object, record(typescript); 结构体是由多个类型组合而成; 它可以给每个类型设置一个名称(key), 所以结构体对比元组更加灵活

我们定义一个结构体, 非常简单:

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

我们需要使用这个结构体构造一个实例, 就更简单啦:

let user1 = User {
      email: String::from("someone@example.com"),
      username: String::from("someusername123"),
      active: true,
      sign_in_count: 1,
  };

和ts一样, 我们定义的结构体需要完全初始化, 即数据模型要和结构体完全匹配.

如果我们需要修改user1的某个字段, 必须把整个user1变为mut可变类型, 结构体不支持仅把某个元素变为可变:

user1.email = String::from("anotheremail@example.com");

我们在结构体中, 对于同名的key和value是可以做到省略简写的, 比如这样:

fn build_user(email: String, username: String) -> User {
    User {
        email,
        username,
        active: true,
        sign_in_count: 1,
    }
}

同样的, 我们在ts中经常使用es的扩张运算符来更新对象, 在rust中, 你完全可以使用类似的语法更新结构体 (必须写在尾部)

let user2 = User {
    email: String::from("another@example.com"),
    ..user1
};

关于更新语句, 所有权也需要关注, 在rust所有权中, 部分类型如果支持copy, 那么数据在这里就会被拷贝, 比如bool和u64都实现了copy, 那么在此时的更新结构体的语句中, 仅仅只是把值copy了一份而已; 但是username是string类型, 在此处是转移了所有权到user2中, 那么在user1中就不能操作username了.

结构体不仅仅有key, value这种形式, 还可以有其他形式

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);

枚举

枚举是一个类型, 有多个枚举成员, 枚举值是其中某个枚举成员的实例

enum PokerSuit {
  Clubs,
  Spades,
  Diamonds,
  Hearts,
}

在一些特殊场景下, 你不仅仅可以指定成员类型, 还可以代替结构体简化代码: 在ts中枚举是运行时类型(经过编译器编译之后为对象), 但是在rust中就是实打实的类型, 不能在声时指定值;

如果使用传统的结构体, 是这样写的

enum PokerSuit {
    Clubs,
    Spades,
    Diamonds,
    Hearts,
}

struct PokerCard {
    suit: PokerSuit,
    value: u8
}

fn main() {
   let c1 = PokerCard {
       suit: PokerSuit::Clubs,
       value: 1,
   };
   let c2 = PokerCard {
       suit: PokerSuit::Diamonds,
       value: 12,
   };
}

我们通过枚举进行改造, 可以大大简化代码:

enum PokerCard {
    Clubs(u8),
    Spades(u8),
    Diamonds(u8),
    Hearts(u8),
}

fn main() {
   let c1 = PokerCard::Spades(5);
   let c2 = PokerCard::Diamonds(13);
}

指定成员类型

enum PokerCard {
    Clubs(u8),
    Spades(u8),
    Diamonds(char),
    Hearts(char),
}

更复杂的枚举成员类型 (结构体)

struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}

相比较元组结构体, 从语法角度上来说枚举的方式更为简洁且高内聚

Option

在rust中避免了使用大多数语言经常用到的null概念, 改为option, 其类型本身就是一个枚举

enum Option<T> {
    Some(T),
    None,
}

some指的是有值(任意值), 类型为t, t是一个泛型参数; none则为空

值得注意的是, option并不需要显式的引入, 因为它本身就被包含在标准库中, 也不需要::调用some和none;

let some_number = Some(5);
let some_string = Some("a string");

let absent_number: Option<i32> = None;

在rust语言设计中, 你如果想使用类似null的概念, 就必须告诉编译器这个null如果有值的话是什么类型, 即第三段代码; 反之如果使用了some, 就代表了值存在于some之中; 那rust为何这么做, 多此一举新增一个option枚举呢?

其实主要核心问题还是安全性, rust编译器要在编译器确保数据的安全, 不能因为null的滥用导致程序错误, 如果不使用option来标记可能为空的值, 那么在之后的代码中你可能就会忘记这个值的类型和其他类型做逻辑运算, 那么此时null就会被爆雷; 所以如果使用option标记值, 在编译期就可以让rust判断, 提醒开发者.

数组

数组很简单, 同样rust可以设置数组的类型

fn main() {
    let a = [1, 2, 3, 4, 5];
        // 这里的类型声明, ;之前是类型, ;之后是重复几次
        let b: [i32; 5] = [1, 2, 3, 4, 5];
        // 同样的也可以通过上述的语法, 让我们初始化数组更快速, 初始化值为3, 重复5次
        let c = [3; 5];
}

访问数组

a[0]

可以进行切片

let slice: &[i32] = &a[1..3];