为什么我不需要声明x具有仿射语义和函数类型的可重用/可复制性?

8

有人告诉我 Rust 在区分逻辑上有一种语义——因此可以进行删除/削弱,但不包括复制/收缩。

以下代码成功编译:

fn throw_away<A, B>(x: A, _y: B) -> A {
    x
}

由于重复是不允许的,因此以下内容无法编译:

fn dup<A>(x: A) -> (A, A) {
    (x, x)
}

同样地,这两个也无法编译:

fn throw_away3<A, B>(x: A, f: fn(A) -> B) -> A {
    x;
    f(x)
}

fn throw_away4<A, B>(x: A, f: fn(A) -> B) -> A {
    throw_away(x, f(x))
}

削弱也是可以观察到的。
fn weaken<A, B, C>(f: fn(A) -> B) -> impl Fn(A, C) -> B {
    move |x: A, y: C| f(x)
}

我们返回的是 impl Fn(A, C) -> B 而非 fn(A, C) -> B。是否有办法返回 fn(A, C) -> B 呢?如果不行也没关系,我只是好奇。

另外,我希望能够将 A 提升为 () -> A。但是,在Rust中函数可以被复制并使用多次。例如:

fn app_twice(f: fn(A) -> A, x: A) -> A {
    f(f(x))
}

假设有一个名为lift(x: A) -> fn() -> A的函数,那么我们可以破坏移动语义。例如,这将允许:
fn dup_allowed(x: A) -> (A, A) {
    let h = lift(x);
    (h(), h())
}

因此,要将 A 提升为 fn() -> A,我们需要知道该函数是“线性/仿射”的,或只能使用一次。Rust 为此提供了一个类型:FnOnce() -> A。在下面的示例中,第一个可以编译,而第二个则不行。
fn app_once(f: impl FnOnce(A) -> A, x: A) -> A {
    f(x)
}

fn app_twice2(f: impl FnOnce(A) -> A, x: A) -> A {
    f(f(x))
}

以下函数互为反函数(可能是这样,我不太清楚Rust的语义是否真正互为反函数):
fn lift_up<A>(x: A) -> impl FnOnce() -> A {
    move || x
}

fn lift_up_r<A>(f: impl FnOnce() -> A) -> A {
    f()
}

由于fn dup<A>(x: A) -> (A, A) { (x,x) }无法编译,我认为以下可能是一个问题:

fn dup<A>(x: fn() -> A) -> (A, A) {
    (x(), x())
}

看起来 Rust 对于类型 fn(A) -> B 做了一些特殊处理。
为什么在上述例子中我不需要声明 x 是可重用/可复制的?
也许有些不同。声明函数是一件特殊的事情,fn f(x: A) -> B { ... } 是表明 A -> B 的一个特定证明。因此,如果需要多次使用 f,它可以被重新证明多次,但是 fn(A) -> B 是完全不同的东西:它不是一个构造出来的东西,而是一个假设性的东西,并且必须使用 fn(A) -> B 来说明它们是可复制的。实际上,我一直认为它更像是一个自由可复制的实体。这是我的简单类比:
  • fn my_fun<A,B>(x :A) -> B { M } 表示 x:A |- M:B
  • fn(A) -> B 表示 !(A -o B),因此可以自由复制
  • 因此,fn () -> A 表示 !(() -o A) = !A ,因此 fn () -> A 是对 A 进行 (co)free 复制
  • fn dup_arg<A: Copy>(x: A) -> B { M } 表示 A 具有复制或是一个余单子
  • impl FnOnce (A) -> B 表示 A -o B
但这不能对,因为 impl Fn(A) -> B 是什么?通过一些尝试,似乎 fn(A) -> BFn(A) -> B 更严格。我错过了什么?

2
您可以随意复制 fn,因为它们是“Copy”,这意味着它们仅持有可轻松克隆的信息,就像数字一样。fn 只是可执行文件中函数的地址,复制它只涉及到复制指针。但对于闭包来说情况并非如此,闭包可能会捕获任意状态,就像您的“lift”示例一样。 - user4815162342
谢谢!当我在顶层声明一个函数,例如fn my_fun(x:A) ...,那么Rust是否会像对待传递给函数的fn(A) -> B参数一样将对my_fun的引用视为地址/指针? - Jonathan Gallagher
1
fn(...) 总是简单引用全局数据。实现 Fn(...) trait 的类型不需要这样。如果将 fn() 传递给通用代码(可以与任何 Fn 一起使用,而不仅仅是一个具体的 fn),那么在通用代码内部无法复制它们,除非您明确指定 + Copy(在这种情况下,lift 返回的函数将不符合条件)。impl Fn(...) 是一个匿名类型,只被知道实现了 Fn(...) trait。你的问题实际上是一个非常有趣的阅读,但我不确定实际问题是什么。 - user4815162342
1个回答

7
fn weaken<A, B, C>(f: fn(A) -> B) -> impl Fn(A, C) -> B {
    move |x: A, y: C| f(x)
}

Instead of returning fn(A, C) -> B, we returned impl Fn(A, C) -> B. Is there a way to return fn(A, C) -> B instead? It's fine if not; I'm just curious.

不可以,因为按照定义,fn不是一个闭包:它不能包含任何未编译到程序中的状态(在本例中,即f的值)。这与您接下来的观察密切相关:因为fn无法关闭任何内容,所以显然不能包含任何非Copy类型,因此始终可以多次调用或复制自身,而不违反我们正在讨论的属性。
准确地说:所有的fn(..) -> _类型都实现了FnCopy(以及FnOnce)。
  • Copy是特殊目的的标记特征(“标记”意味着它不提供方法),其特殊目的是告诉编译器,每当一个类型被使用超过一次时,它可以自动复制该类型的位。任何实现Copy的东西都选择退出移动但不复制的系统——但不能因此违反另一种类型的非拷贝性。
  • Fn是可通过不可变引用调用的函数的特征(不修改或消耗函数本身)。这在原则上与Copy是分离的,但在效果上非常相似;可能会出现的差异(其中一些在普通代码中无法发生)是:
    • 如果一个函数实现了Fn但不是CopyClone,那么您不能将该函数存储在多个位置,但可以随意调用它。
    • 如果一个函数实现了Copy但不是Fn(仅适用于FnOnce),那么这是看不见的,因为每次调用它(除最后一次外)都会隐式复制它。
    • 如果一个函数实现了Clone但不是FnCopy,那么每次调用它时(除了最后一次),您都必须使用.clone()

And indeed the following functions are inverses of eachother (probably, I don't know rust's semantics well enough to say that they are actually inverse to each other):

fn lift_up<A> (x:A) -> impl FnOnce () -> A {move | | x}
fn lift_up_r<A> (f : impl FnOnce () -> A) -> A {f()}

lift_up_r 接受 lift_up 没有生成的函数;例如,如果 f 有副作用、恐慌或挂起,那么let f = lift_up(lift_up_r(f));会有相应的影响。忽略这一点,它们是互逆的。一个更好的互逆对是将值移入和移出 struct 的函数 -- 这实际上是在做这件事,只不过允许输入不是特定的结构类型。


Since fn dup (x:A) -> (A,A) {(x,x)} does not compile, I thought that the following might be a problem:

fn dup<A> (x : fn() -> A) -> (A,A) {(x(),x()}

But it seems that rust is doing something special for fn(A) -> B types. Finally, my question: why don't I have to declare that x is reusable/duplicable in the above?

当你有一个带有类型变量的通用函数 fn dup<A> 时,编译器不会对 A 的属性做出任何假设(除非你选择退出该隐式限制,因为使用非Sized值会受到严格限制,通常不是你想要的)。特别地,它不会假设 A 实现了 Copy

另一方面,正如我上面提到的,所有的 fn 类型都实现了 FnCopy,因此它们总是可以被复制和重用。

编写一个操作通用函数并以你期望的方式 无法编译dup 函数的方法是:

fn dup<A, F>(x: F) -> (A,A)
where
    F: FnOnce() -> A
{
    (x(),x())
}

在这里,我们告诉编译器F是一种通过调用来使用的函数类型,并且不告诉它关于任何复制F的方法。因此,它在编译时失败并报错“error[E0382]: use of moved value: x”。使其编译最简单的方法是添加约束条件F: Copy,而最通用的方法是添加F: Clone和一个显式的.clone()调用。


也许发生了一些不同的事情。声明的函数有点特殊,fn f(x:A) -> B {...}是A -> B的一个特定证明。因此,如果需要多次使用f,则可以根据需要重复证明。但是fn(A) -> B完全不同:它不是一个构造的东西,而是一个假设的东西,并且必须使用fn(A) -> B是可重复的。实际上,我一直在想它更像是一个自由复制的实体。

我不是逻辑学家,但我认为前半部分是不正确的。尤其是,在泛型方面没有与“声明的函数”相关的任何属性,而与类型fn(A) -> B的任意值也没有不同。相反,类型fn(A) -> B的值可以被复制,这种可复制性直接对应于“它可以被重新证明”的事实,因为(除非我们开始引入像JIT代码生成这样的思想),每个类型为fn(A) -> B都指向一个编译好的代码片段(而没有其他数据)——因此编译器已经检查并授权程序在运行时重用它多次。

那么impl Fn(A) -> B是什么?通过实验,似乎fn(A) -> B比Fn(A) -> B更严格。我错过了什么吗?

impl语法有不同的用途,但在参数位置上,它几乎完全是泛型的简写。如果我写:

fn foo<A, B>(f: impl Fn(A) -> B) {}

那相当于

fn foo<A, B, F>(f: F) 
where
   F: Fn(A) -> B
{}

除了存在任何impl参数类型时,调用方不允许指定任何参数(这与您的兴趣无关,但为了准确性我提及它),我们告诉编译器F可以是任何可重复使用函数的可调用类型。特别地,我们没有指定F:CopyF:Clonefn(A) -> B,另一方面,是一个具体实现 Fn(A) -> BCopy的类型,因此你可以免费获得。

在返回位置上,fn ... -> impl Fn(A) -> Bimpl表示一种存在性类型:您断言函数将返回实现Fn的某些类型。编译器跟踪具体的类型以生成代码,但是您的程序避免命名它。当返回闭包时,这是必需的,但返回未涵盖任何内容的函数时是可选的:例如,您可以写成

fn foo<A>() -> fn(A) -> A {
    |x| x
}

哇,感谢您详细的回复!有一件事让我困惑的是,每个fn(A) -> B都是编译时常量。这是一个非常棒的特性!这是因为fn(A) -> B只是一个指针,在编译时被赋予了一个常量地址吗?假设我们编写一个函数“fn w<A> (x:i32) -> fn(A) -> A {...}”,其中如果x <0,则为“|x|x”,否则为“|x|while(true){}x”。然后在另一个泛型函数中,我们可以有一些变量“y:i32”,然后“let h: fn(A) -> A = w(y); ...”。在这种情况下,h是否被推断为常量? - Jonathan Gallagher
我会等到明天才接受你的答案,这样我就可以设置赏金并奖励它。非常感谢你的回答和对我这个新手和无聊问题的耐心。 - Jonathan Gallagher
“这是因为 fn(A) -> B 只是一个指针,在编译时被赋予了一个常量地址吗?”- 地址并不严格是编译时常量,因为它可能会被操作系统的程序加载器/动态链接器修改(实际上,很可能是随机的),但它在进程的生命周期内不会改变。但这些都是更多或更少的实现细节;重要的是 fn 类型的定义包括它实现了 Copy,系统会做必要的事情来确保这一点。 - Kevin Reid
请等到明天再接受您的答案,这样我就可以设置赏金并授予它。@JonathanGallagher即使问题已被回答/接受,您也可以授予赏金。 - Shepmaster

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接