likes
comments
collection
share

Traits: Rust 中的统一概念

作者站长头像
站长
· 阅读数 5

Traits: Rust 中的统一概念

Rust 的traits来自 Haskell 的type classes,它是下边这些的概念的统一:

  • Interfaces

    接口

  • Abstract classes

    抽象类

  • Mix-ins

    混入

  • Operator overloading

    运算符重载

  • Constraints on generics / Concepts 对泛型/概念的限制

  • Behavioral markers / Attributes 行为标记/属性

  • (bonus) Method overloading (奖励)方法重载

这真得很神奇!

我使用过的所有其他语言 [1] 都有上面列表的一些子集。我不认为他们中的任何一个都拥有所有这些,并且它们是具有自己的关键字和语法的不同概念。坦率地说,能够将所有这些统一起来真是太棒了。

Rust 没有特殊的接口语法,因为trait涵盖了这一点。 Rust 没有用于运算符重载的特殊语法,因为trait涵盖了这一点。等等。

为了强调这个想法的灵活性,以下是如何仅使用trait来完成上面列表中的所有操作:

Interfaces 接口

Logger 是一个具有两个方法的接口。它看起来与其他语言非常相似,只不过它显示的是 trait 而不是 interface

trait Logger {
    fn log(&self, s: &str);
    fn err(&self, e: &str);
}

struct StdoutLogger {}

impl Logger for StdoutLogger {
    fn log(&self, s: &str) {
        println!("{}", s);
    }
    fn err(&self, e: &str) {
        self.log(e);
    }
}

fn run(l: &dyn Logger) {
    l.log("Hello");
    l.err("Oops");
}

fn main() {
    let l = StdoutLogger {};
    run(&l);
}

很简单。

Abstract classes 抽象类

抽象类是不能直接实例化的,你必须扩展它来填充缺少的方法。在 Rust 中,这看起来像是具有默认实现的trait。继续上面的例子:

trait Logger {
    fn log(&self, s: &str);
    fn err(&self, e: &str) {
        self.log(e);
    }
}

struct StdoutLogger {}

impl Logger for StdoutLogger {
    fn log(&self, s: &str) {
        println!("{}", s);
    }
}

Logger 现在提供 err 的默认实现,因此 StdoutLogger 不必这样做。 StdoutLogger 仍然可以提供它自己的实现,这将覆盖trait默认值。

Mix-ins 混入

混合是添加到现有类型的行为。在上面的示例中,我们定义了 traitstruct 。您当然可以从不同的包中实现trait。在这里,我们实现了标准库的 Display 特征,这就是 Rust 如何将类型转换为用户友好的字符串(如 Go 的 String() 或 Java 的 toString() )。

struct StdoutLogger {}

impl std::fmt::Display for StdoutLogger {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        writeln!(f, "I am StdoutLogger")
    }
}

fn main() {
    let l = StdoutLogger {};
    println!("{}", l);
}

但你可以用另一种方式做到这一点,这起初让我感到非常惊讶。您可以实现在其他包的类型上定义的trait,从而向它们添加行为(“混合”新行为)。该行为仅对导入您的实现的包可见。

trait SurelyNot {
    fn can_we_build_it(&self);
}
impl SurelyNot for String {
    fn can_we_build_it(&self) {
        println!("Yes we can");
    }
}

fn main() {
    let s = String::new();
    s.can_we_build_it();
}

您必须拥有 traitstruct 才能实现其中一个。你不能为你不拥有的类型实现你不拥有的trait。

Operator overloading 运算符重载

Rust 将其所有运算符( +% 等)映射到 the ops crate中的trait。如果您的类型实现了该trait,则可以使用该运算符。

struct Sponge {
    name: String,
}

impl std::ops::Add for Sponge {
    type Output = Self;
    fn add(self, other: Self) -> Self {
        Sponge {
            name: self.name + "X" + &other.name,
        }
    }
}

fn main() {
    let m1 = Sponge {
        name: "m1".to_string(),
    };
    let m2 = Sponge {
        name: "m2".to_string(),
    };

    let m3 = m1 + m2;
    println!("{}", m3.name);
}

仍然只是trait。没什么额外的。

Constraints on generics 对泛型的限制

这就是 C++ 所说的概念,Java 所说的“有界类型参数 bounded type parameters”,Go 泛型提案所说的“类型约束 type constraints”。如果您可以将任何接口定义为trait,并且还可以将任何运算符定义为trait,那么您现在就拥有了约束泛型类型参数所需的一切。

Here’s a Nameable trait for things that can have their name set. We restrict it to being called with types that can be converted to a string (that implement the ToString trait). 这是可以设置名称的事物的 Nameable 特征。我们将其限制为使用可以转换为字符串的类型(实现 ToString 特征)来调用。

trait Nameable<T: std::string::ToString> {
    fn set_name(&mut self, T);
}

多亏了trait,这很简单。通常情况并非如此!限制泛型通常是一个难题。

C++ 在 90 年代中期出现了泛型(STL)。它几乎在 C++11 中得到了概念,在 C++17 中再次非常接近,并最终在 15 年后将它们合并到 C++20 中。

十年来,Go 一直在尝试添加泛型。多年来,它一直在努力通过多种形式的contract来限制泛型,然后才决定回避这个问题。在最新的提案中,您可以提供泛型类型可能的具体类型列表,而不是一般约束。

Behavioral markers / Attributes 行为标记/属性

Rust 定义了一些空marker traits来通知编译器有关类型的信息。这感觉类似于 C/C++/C# 的 attributes。

由于 Rust 也具有实际attributes,这有点复杂。公平地说,Rust 几乎没有什么东西是不具备的。它是一门大语言。

行为marker的一个例子是 Copy 特征。它说你的类型可以通过复制它的位来复制。例如,您可以为仅保存整数的类型实现此功能(实际上您“派生”它,因为它没有方法)。一个反例是保存内存或文件引用的类型;那不会是 Copy

Bonus: Method overloading, with generics

奖励:使用泛型的方法重载

Rust 没有正式的方法重载,出于同样的原因许多语言没有:如果方法不同,给它们不同的名称。然而,有两种不同的方法可以让你做一些感觉相同的事情。

如果您实现多个定义具有相同名称的方法的trait,Rust 有一种解决歧义的方法(单个trait不能多次使用同一个名称,即使参数不同)。这需要新的语法( TheTrait::method(&myObj) ),因此我没有将其包含在简介的列表中。它不再是“只是trait”。

更好的方法是使用泛型。我也没有将其包含在原始列表中,因为它不再只是提供功能的trait,而是泛型+trait。下面是一个可以采用 字符串或整数的方法 ( set_name ) 示例。

trait Nameable<T> {
    fn set_name(&mut self, T);
}

struct Cyborg{
    name: Option<String>,
}

impl Nameable<&str> for Cyborg {
    fn set_name(&mut self, s: &str) {
        self.name = Some(s.to_string());
    }
}

impl Nameable<usize> for Cyborg {
    fn set_name(&mut self, serial_number: usize) {
        self.name = Some(serial_number.to_string());
    }
}

fn main() {
    let mut mostly_human = Cyborg{name: None};
    mostly_human.set_name("Bob");

    let mut mostly_machine = Cyborg{name: None};
    mostly_machine.set_name(2077);

    println!("{} vs {}",
        mostly_human.name.unwrap(),
        mostly_machine.name.unwrap(),
    );
}

请注意,实际上您只需实现一次,接受任何实现 ToStr 的内容(如上面的“泛型约束”部分所示),然后调用 to_string() 。这个例子有点难,不过,还好。

它变得更好了。方法的返回类型可以是泛型的。这是 Rust 中我最喜欢的东西之一。调用的函数取决于您将输出分配给的变量的类型。一探究竟:

trait Position<T> {
    fn pos(&self) -> T;
}

struct Location {
    lat: f32,
    lon: f32,
}

impl Position<String> for Location {
    fn pos(&self) -> String {
        format!("{},{}", self.lat, self.lon)
    }
}

#[derive(Debug)]
struct Point {
    x: f32,
    y: f32,
}

impl Position<Point> for Location {
    fn pos(&self) -> Point {
        Point {
            x: self.lat,
            y: self.lon,
        }
    }
}

fn main() {
    let l = Location {
        lat: 37.05655,
        lon: -121.88823,
    };

    let s: String = l.pos();
    let p: Point = l.pos();
    println!("As string: {}", s);
    println!("As point: {:?}", p);
}

看看我们在底部如何调用 l.pos() 两次,它返回不同的东西!我喜欢它。

标准库在解析中充分利用了这一点,它将字符串解析为大量其他类型,如果您实现 FromStr ,则包括您自己的类型。它就像 atoiato* 。我使用它的次数远远超出了我应该使用的范围,因为它让我感到快乐。

结论

Rust 官方博客对in-depth discussion of traits有更深入的讨论。如果您已经读到这里,那么接下来您将阅读一篇很棒的文章。

鉴于您可以将所有不同的想法纳入traits中,有什么理由为什么未来的语言不使用它们呢?


Traits: Rust's unifying concept