Rust C++小记录

Rust/C++


​ 很久之前(3个月前吧)写的文档了,当时还在学Rust以及零零散散地学一些C++STL库用法(哪知《effective modern C++》这般好书?)。Rust部分的记录还挺有趣的,C++部分就没有那么有趣了。索性就贴在此处,供日后查阅。(不过很长啊,这叫snippet?)


Rust 部分

​ 个人认为可以这么理解:lib.rs声明了一个crate中所包含的模块,比如下面这个例子:

1
2
3
4
5
src/
├── add.rs
├── sub.rs
├── main.rs
└── lib.rs

​ 则add,rs 以及sub.rs是此crate的两个模块,在lib.rs对此两模块的“存在性”进行声明。一般是:

1
2
mod add;		// 可以加pub
mod sub; // 可以加pub

​ 对于与这些模块处于同一目录的main.rs,可以不需要lib.rs就在main.rs中通过mod xxx;的方式声明模块。但如果可执行文件与模块不在同一目录下,貌似(据我所知)只能通过:

1
2
use crate_name::add;
use crate_name::sub;

​ 这样的方式调用模块。这样调用模块必须通过lib.rs对模块进行声明!否则编译将出现:找不到模块 的错误。而另一方面,mod.rs是什么?有什么作用?对于一个大型的模块,我们很可能将其拆为多个文件。比如add.rs,我们将其拆分为:add_inplace.rsadd_2_digits.rsadd_more_than2.rs三个文件,我们可以将三个文件按照如下所示的方式进行整理:

1
2
3
4
5
src
├── add
... ├── add_inplace.rs
├── add_2_digits.rs
└── add_more_than2.rs

​ 此时我们需要mod.rs来同一整个模块内部的所有子模块。在add/中建立叫做mod.rs的文件,或者与add/同级建立add.rs目录,内部声明子模块:

1
2
3
mod add_inplace;
mod add_2_digits;
mod add_more_than2;

​ 故简单来说:lib.rs声明一个crate下的所有模块,mod.rs声明一个模块下的所有子模块。

​ P.S: 在use语句引入的模块名过长时,可以使用as重命名。例如假设有个叫做numpy的模块,我们可以use numpy as np;


  • static定义的值一般是全局变量,具有超长声明周期。注意由于static定义全局 变量,其结果是有地址的。

  • const定义的是真正的常量,是compile time可知的值,类似于C++中的constexpr(大多数情况下,constexpr都是compile time variable)。对应地,Rust也存在const fn,作用类似于C++的constexpr函数。如果要使得一个函数输出const值,则此函数需要是const fn

  • 在C++中,如果编译器发现我们在给一个constexpr值取地址(&),则编译器将会给此值分配一个地址,否则一般来说,constexpr值不会占用内存。


​ 一般来说,除非变量已经是iterator(或者IntoIter等等),for循环一般都是针对x.into_iter()x.iter_mut()x.iter()三种形式进行的:

  • into_iter返回值视上下文而定。比如某个容器中持有的是reference,那么将返回reference,是值就返回值。into_iter在遍历过程中将发生move,原容器将被consumed,不再有效,但返回的是具有所有权的内容。
  • iteriter_mut一般则(分别)返回immutable references 以及 mutable references,不会消耗原有容器。

​ iterator内部具有很多有用的函数:

  • advance_by:与next不同,advance_by是非单步的移动,可以传入一值确定iterator前进的步数。超过返回返回Err。
  • allany,两个函数均可以传入闭包,用以判定每一个元素是否满足要求。含义与python的allany一致。
  • (常用)collect,个人感觉类似于from_iter。事实上,此函数返回一个FromIterator。也即可以由iterator构造一些数据结构,如vec。比如:
1
let vector: Vec<usize> = (0..=10).map(|x| {x * x + 2 * x}).collect();

​ 其中,左边的type是必须要确定的,否则collect无法得知自己应该返回什么类型。

  • chain:类似于extend或者concatenatea.iter().chain(b.iter())将返回串联的iterator,用于遍历等。
  • cycle:将iterator首位串接,使得iterator成为环状
  • (常用)filter:传入闭包,返回一个新的iterator,新的iterator中只含有闭包函数确定的值。比如一个判定偶数的函数等。
  • (常用)filter_map:传入闭包,此闭包函数返回Option。对于返回值为Some<T>的值,将被放入返回的迭代器中。相当于.filter().map()
  • flatten:当结构中存在nested iterator时,可以使用此flatten展平。例如:Vec<Vec<i32>>,可以用flatten先展开(返回IntoIterator),再collect。
  • (常用)fold:对此iterator进行一个压缩操作,例如(0..10).fold(0, |acc, x|{acc + x})可以求累加操作。
  • (常用)for_each:传入一个闭包,对每一个元素执行操作。
  • (常用)last:不过注意,这应该是一个O(n)操作
  • (常用)nthlast就是nth的一个特例而已
  • partition:consume此iterator,并返回两个collection(比如两个vec)。partition传入一个闭包,可以认为此闭包函数返回值为true的元素在一个collection中,反之则在另一个collection中。注意,由于对于iter()返回的iterator而言,如果原容器内部元素的类型是T,那么iterator的Self::Item就是&T。而partition输入的闭包要求传入&Self::Item,也即最后,输入将会是&&T,此时可以在闭包函数参数前加&用于dereference
  • product:相当于返回单值的cumprod
  • reduce:使用某一函数将值压缩为一个(与fold几乎是一致的,只不过不需要提供初值)
  • (常用)rev:反向迭代。并且注意反向迭代器与正向迭代器不是同一个类型。
  • skip:输入一个值n,迭代器将从start+n开始(跳过初始值)。skip_while:输入一个闭包(返回true | false),直到闭包返回false之前的所有值都会被跳过。
  • step_by:相当于python range(0, end, step)
  • (常用)take:传入一个值n,迭代器最多取到start+n (skip的对称操作)。take_while:输入闭包,直到闭包返回false之前的所有值都会被取出到新的iterator中。注意此take与Option的take不同,Option的take应该会使得原值为None(可以认为这是某种意义上的consume),但iterator的take不会修改或消耗原值
  • (常用)zipunzip:类似于python,zip将两个迭代器打包成一个迭代器,此迭代器是两个迭代器元素的tuple。unzip则将这样的迭代器重新拆为两个容器(不一定是迭代器,可以是Vec)

​ 曾经我想搞清楚为什么我无法确定closure的类型,问题是这样的:我想返回某个iterator经过map后的结果,如:

1
2
3
fn map_test() -> <?> {
(0..10).map(|i| {i * 2})
}

​ 但是一直无法写对返回值。rust-analyzer告诉我,返回值应该是Map<Range<i32>, |i32| -> i32>。我尝试填在返回值type annotation中,报错,无法直接填Map<Range<i32>, |i32| -> i32>,编译器无法解析|i32| -> i32(存在语法错误)。后来我又尝试了一些魔法,诸如:

1
2
3
fn map_test<T>() -> Map<Range<i32>, T> where T: Fn(i32) -> i32 {
(0..10).map(|i| {i * 2})
}

​ 还是不行。报错大意:返回值是map(closure xxxxx (文件名,行列号))而需要T。也就是说,返回值类型不匹配。

​ 之后我在stackoverflow上偶然看到这样的问题:有人想把匿名闭包推入Vec中,但是Vec无法解析匿名闭包的类型。下面的佬这么回答到:

Each closure has an auto-generated, unique, anonymous type. As soon as you add the first closure to the vector, that is the type of all items in the vector. However, when you try to add the second closure, it has a different auto-generated, unique, anonymous type, and so you get the error listed.

​ 也就是说,确定closure的类型貌似是不可能的。虽然如下代码:

1
2
3
let f1 = |i: i32| -> i32 {i * 2};			// Ok
let f2 = |i: i32| {i * 2}; // Ok
let f3 = |i| -> i32 {i * 2}; // Err

​ 前两句可以通过编译,rust-analyzer也会推导出|i32| -> i32的类型来,但想要给f1等变量进行type annotation是做不到的。此外注意,如果闭包的输入不进行type annotation(如f3),将会报错。而输出类型可以不要(编译器可以推导出来)。


​ Rust Programming Language一书全面地介绍了trait的性质。其基本使用方法是:

1
2
3
pub trait TraitName {
fn your_method(<args...>) -> ReturnType;
}

​ 也即我们可以自定义Trait(替换TraitName),并且限定此trait中的接口。所有具有此trait的类型,都需要自定义这些接口。可以理解为:Trait类似于基类,其中存在虚函数。我们在学习链表时已经接触过,如果需要链表具有迭代器,需要:

1
2
3
4
5
6
impl<T> Iterator for LinkIterator<T> {
type Item = ...;
fn next(&self) -> Option<Self::Item> {
...
}
}

​ 也即使得类型LinkIterator具有特性Iterator。类似于虚函数实际上是可以有默认定义的,trait method也可以在定义trait时定义默认实现。对于使用此特性的类型,如果要使用默认方法,在impl块中不重定义此trait method即可。特性中的方法,可以类似于结构体方法进行调用.method(),并且,特性方法是可以在类型之间共享的。

​ 更有趣的来了:在C++泛型编程中,假设我有这样的一个模板函数,此模板函数接受一个类型T,我需要调用类型T中的某个方法.method(),也即类型T必须保证其实现了此方法,应该怎么做?以个人的理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
class SomeBaseClass {
public:
SomeBaseClass(int val): val(val) {}
virtual void printVal() const {
printf("val is %d\n", val);
}
protected:
int val;
};
class SomeClass : public SomeBaseClass {
public:
SomeClass(int val): SomeBaseClass(val) {}
};
class MutatedClass : public SomeBaseClass {
public:
MutatedClass(int val): SomeBaseClass(val) {}
void printVal() const override {
printf("mutated class val is %d\n", val);
}
};
void callPrintVal(const SomeBaseClass& base) {
base.printVal();
}
int main() {
SomeClass class1(5);
MutatedClass class2(6);
callPrintVal(class1);
callPrintVal(class2);
return 0;
}

​ 这样可以使得所有继承了SomeBaseClass的子类可以进行调用。而Rust没有struct的继承,也不能这样隐式转换类型,如果我们需要实现类似的功能,使得函数可以操作某一个实现了某一功能的全体类型,应该怎么做?答案是:使用impl TraitObject作为参数,或者使用trait bounds:

1
2
3
4
5
6
7
8
9
pub fn some_func(item: &impl TraitObject) {
item.method();
}
pub fn some_func<T: TraitObject>(item: &T) {
item.method();
}
pub fn some_func<T>(item: &T) where T: TraitObject {
item.method();
}

​ 故只要知道第一种写法与后两种写法具有类似的功能,只不过后两种写法传入的是类型。而Book则给出了一个具体的区别:

1
2
pub fn notify(item1: &impl Summary, item2: &impl Summary) {...}
pub fn notify<T: Summary>(item1: &T, item2: &T) {...}

​ 上下两行代码均可以接受任意实现了Summary特性的类型,但是第二个函数显式要求了两个参数item1,item2具有相同的类型。

​ 另一个有趣的方面:返回值是impl TraitObject时?意义类似:我们希望返回值实现了此特性TraitObject。所以我们可以用此方法解决闭包中提到的返回map(闭包)问题:

1
2
3
fn map_test() -> impl Iterator {
(0..10).map(|i| {i * 2})
}

​ 但返回值的type提示是:impl Iterator。并且当我想按如下方式进行遍历时,报错了:

1
2
3
4
let result = map_test();
for x in result.iter() {
...
}

​ 原因很简单:.iter()非Iterator对象返回实现了Iterator特性对象时的方法。而由于实现了Iterator特性的对象,可以直接使用for循环遍历,也不必使用.iter()。(事实上,只需要看看result.next存不存在就知道了)。用Rust Reference的一段话总结一下就是:

impl Trait provides ways to specify unnamed but concrete types that implement a specific trait. It can appear in two sorts of places: argument position (where it can act as an anonymous type parameter to functions), and return position (where it can act as an abstract return type).


​ Rust Book如是解释move关键字:

Capture a closure's environment by value.

move converts any variables captured by reference or mutable reference to variables captured by value.

​ 也即强制持有closure从环境中捕获的变量,将变量所有权转移给closure。关于这一切的解释,stackoverflow上有一个非常清晰的回答(排名前二的回答都很清晰易懂),并且二楼还顺带介绍了一下FnOnceFnFnMut特性。


​ 很有趣的一个关键字。已知使用impl TraitObject,函数可以返回一个任意实现了TraitObject的类型。但如果我们需要持有数个这样的类型,将其存在容器里应该怎么办呢?尝试:

1
2
let vec: Vec<impl TraitObject> = Vec::new();	// Error: `impl Trait` only allowed in function and inherent method return types, not in variable binding
let vec: Vec<TraitObject> = Vec::new(); // Error: trait objects must include the `dyn` keyword

​ 这有两个错:(1)Vec并不知道实现了TraitObject的类型都有些什么,类型是 动态的,故需要使用dyn。(2)实现了TraitObject的类型并没有确定的大小(trait bound Sized没有被满足),故无法放在Vec中。故综上所述,正确的书写应该是:

1
let vec: Vec<Box<dyn TraitObject>> = Vec::new();	// 动态类型 + Box(堆分配)确定大小

  • Self是当前object自身的类型(注意,Rust中只有与类型、特性有关的内容才会有首字母大写,语法提示甚至不建议你使用驼峰命名法)

  • self:当前object

​ 这里主要讲一下to_owned函数。to_owned是特性std::borrow::ToOwned提供的方法。官方文档是这样说的:

A generalization of Clone to borrowed data.

Some types make it possible to go from borrowed to owned, usually by implementing the Clone trait. But Clone works only for going from &T to T. The ToOwned trait generalizes Clone to construct owned data from any borrow of a given type.

​ 这里不多说了。文档举了两个例子说明clone只能由&TT,而to_owned灵活多变:

  • &str通过to_owned方法转为String  &[i32](slice)通过to_owned转化为Vec。

C++部分

​ 最近在看C++与Rust的asynchronous programming部分,加上好久没有写多线程程序了,故简单回顾一下一些基础库。future在这就不提了。

unique_lock - lock_guard - shared_mutex - scoped_lock

​ 在讨论到这些锁的时候会讨论到一个概念:RAII(resource acquisition is initialization),RAII的好处是(只是浅显地了解一下):

  • RAII avoids using objects in an invalid state. RAII simplifies cleanup after partial construction.[1] 比如如果构造函数失败了,此object将不会进行析构(资源在构造函数结束就已经被释放了)
  • RAII is about scopes。具体指:获取资源时调用constructor,资源无效时(比如删除,或者在范围外)自动调用析构函数。
  • RAII是另一种形式的garbage collector,用于申请以及释放资源。

​ lock的RAII feature则具体指的是以下几个例子:

  • std::lock 与 std::unlock都做不到RAII,因为本质上他们是对一个存在的锁对象进行操作。没有RAII意味着如果上锁了,就必须要手动解锁,否则可能出现问题
  • std::lock_guard与std::unique_lock都有RAII属性:lock_guard不仅仅是自动解锁,在构造时还会自动上锁。unique_lock则提供了deferred选项,使得其在构造时不立刻上锁。自动解锁是一个值得拥有的属性。
std::unique_lock

​ unique_lock通常与conditional variable一起使用。由于conditional variable(简称cv)使用时通常需要等待一个锁(对应的状态)变为可用:

1
cv.wait(mut);

​ 如果mut是普通的mutex,这里会有一个问题:如果wait过程中发生了错误,就会造成mut无法在wait结束进行unlock,这使得出错之后,很难进行exception handling(emmm,大概这就是not exception safe?)。如果使用unique_lock作为mut,unique_lock可以利用RAII特性在exception时进行解锁。并且unique_lock可以很好地保证自身是持有mutex的,以免已有的mutex被删除、move后,尝试对一个不存在的锁进行操作。

​ unique_lock也可以手动解锁(unlock方法)。lock_guard就没那么方便了。

​ 这里补充一下,std::unique_lock以及mutex本身是否可以被引用呢?

std::scoped_lock & std::lock_guard

​ stackoverflow上有不止一条答案称std::scoped_lock强于std::lock_guard,建议是:

  1. lock_guard if you need to lock exactly 1 mutex for an entire scope.
  2. scoped_lock if you need to lock a number of mutexes that is not exactly 1.
  3. unique_lock if you need to unlock within the scope of the block (which includes use with a condition_variable).

​ scoped_lock的实现上更倾向于进行多mutex操作(scoped_lock具有可变个数参数模板),unique_lock则已经说了:在与cv一起使用时最大的好处是exception safe并且保证锁的持有(不持有就报错),并且可手动解锁。lock_guard像是一个特例化的scoped_lock。

​ 不得不说stackoverflow是一个好网站,会有顶尖C++开发者回答你的问题,甚至包括STL标准委员会的大哥:

When I brought it up in committee, the answer was "nothing." ---Howard Hinnant

std::shared_mutex

​ shared_mutex 使用场景很明确:读者写者模型。多个读者读时,mutex可以多次上锁(内部有锁计数器?)只有计数器为0时,写者才能写。那么不使用shared_mutex应该如何实现呢?计数器mc锁以及一个计数器cnt

​ 对于读者而言:

graph TB

A(mc.lock)
B(cnt+=1)
C(mc.unlock)
D(READ)
E(mc.lock)
F(cnt-=1)
G(mc.unlock)
A-->B-->C-->D-->E-->F-->G

​ 对于写者而言:

graph TB

graph LR;
A(mc.lock);
B(cnt==0 ?);
C(READ);
D(mc.unlock);

A-->B-->|Yes|C-->D
B-->|No|D

​ 应该不会存在死锁的问题。对于shared_mutex而言,上述功能被直接包含在了此数据结构中。


Reference

[1] https://stackoverflow.com/questions/712639/understanding-the-meaning-of-the-term-and-the-concept-raii-resource-acquisiti#:~:text=RAII%20avoids%20using%20objects%20in,we%20even%20use%20the%20object.&text=There%20are%20three%20error%20cases,but%20copying%20the%20files%20failed.