闭包--C# lamda VS Rust闭包
Rust中闭包捕获环境变量(上下文)有三种形式,捕获变量的不可变借用,捕获变量的可变借用,捕获变量所有权。
捕获变量的不可变借用就是Fn trait,该闭包可以多次执行。
捕获变量的可变借用就是FnMut trait,该闭包可以多次执行,但需将该闭包对象声明为mut,即表明该trait对象会修改环境变量。
捕获变量所有权就是FnOnce trait,由于该闭包捕获了变量所有权,所以只能执行一次,且使用move明确的将变量的所有权移动到闭包中。
以上这些都是老生常谈的东西了。
不过我发现了另一个有意思的问题,变量捕获是发生在什么时候,是在闭包执行时?还是在闭包定义时?
“凭借”(其实是想当然)我多年的C#开发经验,一开始想当然的是发生在执行时,C#就是发生在执行时(其实不对),代码证明如下:
C#Lamda表达式对环境变量的捕获
internal class LamdaFun1
{
public void Run()
{
//此处特意使用了引用类型的变量和值类型变量,但在lamda中调用时,他俩的表现是一致的
StringBuilder builder =new StringBuilder("a");
int x = 1;
var lamda = () =>
{
Console.WriteLine(builder);
Console.WriteLine(x);
};
for (int i = 0; i < 5; i++)
{
x++;
lamda();
builder.Append(i);
}
Console.WriteLine(builder.ToString());
Console.WriteLine(x);
}
}
上面代码打印结果如下:
a0
2
a01
3
a012
4
a0123
5
a01234
6
a01234
6
从结果来分析,在lamda执行时,每次执行都会重新读取所引用变量(builder和x)的值,尽管x是值类型,但C#并不会进行值拷贝,反而引用(或类似引用类型方式调用),最终的打印也说明,两个外部的变量确实进行了修改。
Rust闭包对环境变量的捕获
let mut str="Hello ".to_string();
let mut closure_fn=move ||{
str.push_str("world! ");
println!("{str}");
};
closure_fn();
closure_fn();
在执行这段代码之前,我完全没想到输出结果。
甚至我一开始都怀疑这段代码能否通过编译,因为已经使用move将str的所有权移动到闭包中,然后因为closure_fn持有str的所有权,在执行一次后就会释放,当然事实证明我想错了。
上面代码的执行结果:
Hello world!
Hello world! world!
上面的代码看似简单,但其实有几个关键点
- closure_fn 是FnMut,不是FnOnce,不能想当然的看到move就认为是FnOnce,关键要看在闭包中如何使用环境变量。move关键字只是表明该变量移动到闭包内,归闭包所有,不是有move就是FnOnce,在闭包内,push_str只需要str变量的&mut Self,因此该闭包为FnMut。
- 理解闭包为FnMut后,第二条输出的结果就好理解了,move将变量移动到闭包中,变量的生命周期与闭包相同,然后执行两次而已。
闭包捕获变量的时机
再然后就有了最上面的疑问,Rust有严格的变量所有权和生命周期控制,闭包是在什么时候捕获环境变量的,经过思考不难发现,是在闭包声明(定义)的时候。例如以下代码就无法执行
let mut str="Hello ".to_string();
let mut closure_fn=move ||{
str.push_str("world! ");
println!("{str}");
};
//报错:borrow of moved value: `str`
println!("{}",str);
报错告诉你,str已经移动到闭包中了,你无法在此处使用,尽管没有执行闭包。
但,总想找找有没有例外的情况,假如一个场景,需要在闭包内读取变量,在闭包外还要修改变量该怎么办呢?
自然,想到了使用Cell和RefCell,代码如下:
//使用RefCell的
let str = "a".to_string();
let cell = RefCell::new(str);
let print = || println!("{}", cell.borrow());
cell.borrow_mut().push_str(" hello world");
print();
//使用Cell的
let i = Cell::new(0);
let f = || println!("{}", i.get());
i.set(i.get() + 1);
f();
以上代码的逻辑很简单
- 先让闭包捕获环境变量
- 在闭包外修改变量
- 执行闭包打印变量的值
结果也没问题,都可以打印出修改后的值。
经过之前的也可以得出原因,两个闭包因为只使用了&self,因此都是Fn(),所以...,而且也能推断出,即便将修改值的逻辑放在闭包内也没问题,因为不管是RefCell的borrow_mut方法还是Cell的set方法,都是&self,因此闭包也还是Fn()。
结论
经过分析,明确了以下几点
1.闭包获取变量是在声明时,而不是在运行时。
2. 判断闭包是FnMut还是FnOnce,不是看变量是否move到闭包内,而是看闭包内使用变量的方式,使用的&mut Self就是FnMut,使用的Self就是FnOnce。
3. 如果要在闭包内外同时访问变量,且保持可修改,单线程下使用RefCell或Cell,多线程下使用Arc
以下是错误的,保留下来仅做为思考路径展示
另外,反过来再想想C# 中Lamda表达式捕获环境变量的逻辑,应该跟Rust是一样的,必须在闭包声明时就明确捕获哪些变量,而且应该是记录了该变量的内存位置(比如说值类型的变量),不然无法获取到变量更新后的值。但还有一点,如果变量的地址发生了改变(数组扩容,导致移动了内存位置),是如何通知已经捕获该变量的lamda表达式的?或者说如何避免这种情况?
以下为更正内容
在C#中,lamda表达式捕获变量是靠编译器将被捕获变量提升到一个隐藏类中,之后不管在lamda内部或外部对该变量的修改都修改的是那个隐藏类中的变量,打个比方就是,编译器将被捕获的变量偷偷的放到一个小盒子中,变量就是小盒子中的一个物件,之后不管是lamda还是其他对该变量的修改,都是修改的小盒子内的那一个,因此不管是变量地址发生改变,还是值发生改变,在任何地方都可以读取到正确的值。

浙公网安备 33010602011771号