分享

为结构体定义方法

 古明地觉O_o 2022-12-08 发布于北京

楔子

上一篇文章我们介绍了 Rust 的结构体,而结构体是可以绑定方法的,那么什么是方法呢?先来回顾一下,介绍结构体时举的一个例子。

struct Rectangle {
    width: u32,
    height: u32,
}

fn get_area(rectangle: &Rectangle) -> u32 {
    rectangle.width * rectangle.height
}

fn main() {
    let rect = Rectangle { width: 30, height: 50 };
    println!("{}", get_area(&rect));  // 1500
}

定义了一个 get_area 函数,接收结构体的引用,计算矩形的面积,这是上一篇文章中举的例子。

虽然代码没有问题,但这里的 get_area 函数其实是非常有针对性的:它只会输出矩形的面积。因此,将其行为与 Rectangle 结构体本身结合得更加紧密一些,可以让我们更容易地理解它的含义。

接下来,我们会把 get_area 函数转变为 Rectangle 的方法来继续重构当前的代码。


定义方法

方法与函数十分相似:

  • 它们都使用 fn 关键字及一个名称来进行声明;

  • 它们都可以拥有参数和返回值;

  • 它们都包含了一段在调用时执行的代码。

但方法与函数依然是两个不同的概念,因为方法总是被定义在某个结构体(或者枚举类型、trait 对象)的上下文中,并且它们的第一个参数永远都是 self,用于指代调用该方法的结构体实例。

现在让我们把那个以 &Rectangle 作为参数的 get_area函数,改写为定义在 Rectangle 结构体中的 get_area 方法,如下所示。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn get_area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect = Rectangle { width: 30, height: 50 };
    println!("{}", rect.get_area());  // 1500
}

代码应该不难理解,把里面 impl 想象成 C++ 的 class 即可,只不过 C++ 的成员字段和方法是结合在一起的,而 Rust 的成员字段和方法游离成了两部分。也就是在 Rust 中需要先定义这样一个结构体,然后再使用 impl 为这个结构体声明方法。

而当我们声明 Rectangle 结构体实例之后,就可以直接调用它里面的方法了。

接下来是方法里面的 self 参数,显然它指的就是实例本身,并且必须叫 self(一个关键字)。当实例调用方法时,会自动将自身作为第一个参数传递给 self,通过 self 可以拿到里面的 width 和 height,显然此处就和 Python 很类似了。所以尽管是新的语法,但语言都是相通的,理解起来并不难。

最后一个重点,那就是方法里面的 self 前面有一个 &,因为结构体实例在传递的时候会转移所有权。我们说实例调用方法时会将自身传递给方法的第一个参数,如果这里使用的是 self,那么 main 函数中的 rect 在传递之后就会发生所有权的转移。所以实例在调用完这个方法之后就不可以再用了,而方法调用结束之后实例也会被销毁。

我们实际演示一下:

当我们在调用 get_area 方法的时候,所有权已经转移了(move),然后再使用 rect 变量就会发生错误。而访问结构体字段相当于借用,于是报错:value borrowed here after move。而和这个错误类似的还有:value used here after move,意思是变量的所有权在转移之后又被使用了。

两种错误类似,前者是变量的所有权转移之后,还将自己的引用借给别人使用;而后者则是,变量的所有权转移之后,又被使用了。当然不管哪一种,都是指我们使用了一个已经转移了所有权的变量。

所以我们一定要声明为 &self,这样实例在调用方法的时候只是传递了一个引用过去,所有权还在自己手里,方法调用完毕之后可以继续使用。

不过上面这种声明方式要求实例在调用方法时,内部成员的值不能发生改变,否则报错。但如果我们就想改变呢?很简单,将 &self 声明为 &mut self 、也就是可变引用即可。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    // 此方法里的 self 是不可变引用
    fn get_area(&self) -> u32 {
        self.width * self.height
    }

    // 此方法里的 self 是可变引用
    fn change_area(&mut self, width: u32, height: u32) {
        // 如果不是可变引用,那么此处不能修改
        self.width = width;
        self.height = height;
    }
}

fn main() {
    // 如果是可变引用,那么它指向的值也要是可变的
    // 因为可变引用指的是:允许通过该引用修改其指向的值
    // 至于能否修改成功,则还要看值是否可变,所以值可变是前提
    // 如果值都不可变,那声明的引用可变又有什么意义呢?
    // 而且事实上,如果值不可变,那么获取它的可变引用会出现编译错误。
    // 但值可变,获取它的不可变引用是可以的
    // 因此如果想通过引用修改具体的值,那么要同时满足以下两点:
    // 1)值是可变的,否则无法获取可变引用;
    // 2)声明的引用也是可变的,否则压根就不允许通过引用去修改
    // 说了这么多,只是为了指出这里的 rect 应该用 let mut 去声明
    let mut rect = Rectangle{width: 30, height: 50};
    println!("area = {}", rect.get_area());  // area = 1500
    // 修改成员
    rect.change_area(4060);
    println!("area = {}", rect.get_area());  // area = 2400
    println!("{:?}", rect)  
    // Rectangle { width: 40, height: 60 }
}

的来说不难理解,并且这里的 &self 我们没有指定类型,也不需要指定类型。因为它是绑定在 Rectangle 结构体上的方法,所以它必然是 &Rectangle 类型。

因此使用方法替代函数,不仅能够避免在每个方法的签名中重复指定类型,还有助于我们组织代码的结构。我们可以将某个类型的实例需要的功能放置在同一个 impl 块中,从而避免在代码库中盲目地搜索它们。


运算符 -> 到哪里去了

不知道你是否发现刚才的例子有点奇怪,我们说实例在调用方法时会自动将自身传给 self,但方法里面的 self 是一个引用,而我们传递过去的是实例,不会冲突吗?

这里我们首先从运算符 -> 说起,在 C++ 中调用方法时有两个不同的运算符:它们分别是直接用于对象本身的 . 以及用于对象指针的 ->。假如 object 是一个指针,那么 object -> something() 的写法实际上等价于 (*object).something()

而 Rust 虽然没有提供 -> 运算符,但作为替代,Rust 设计了一种名为自动引用和解引用的功能,方法调用就是拥有这种功能的地方之一。它的工作模式如下:当你使用 object.something() 调用方法时,Rust 会自动为 object 添加 &、&mut 或 *,以使其能够符合方法的签名。换句话说,下面两种方法调用是等价的:

p.something()
(&p).something()

第一种调用看上去要简捷得多,这种自动引用行为之所以能够行得通,是因为方法有一个明确的作用对象:self 的类型。在给出调用者和方法名的前提下,Rust 可以准确地推导出方法是否只读(&self),是否需要修改数据(&mut self),是否会获取数据的所有权(self),这种针对方法调用者的隐式借用在实践中可以让所有权系统更加友好且易于使用。

对比 Go 的话,会发现这里很像 Go 的值接收者和指针接收者。如果方法是值接收者,那么指针调用的时候会自动转成值去调用;如果方法是指针接收者,那么值调用的时候会自动获取指针进行调用。所以在 Go 里面是值调用还是指针调用不重要,重要的是方法里面的接收者是值接收者,还是指针接收者。

Rust 也与之相同,我们是使用实例(rect)调用还是引用(&rect)调用也不重要,重要的是方法的第一个参数接收的是实例还是引用。只不过在 Rust 中,方法的第一个参数都应该是引用。

println!("area = {}"
        (&rect).get_area());  // area = 1500

调用方法时,将 rect 写成 &rect 也是可以的。

那么下面让我们实现一个新的方法,判断一个矩形能否完全包含另一个矩形,能的话返回 true,不能的话返回 false。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn get_area(&self) -> u32 {
        self.width * self.height
    }
    fn can_hold(&self, other_rect: &Rectangle) -> bool {
        if self.width >= other_rect.width &&
           self.height >= other_rect.height {
            true
        } else {
            false
        }
    }
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };
    let rect2 = Rectangle { width: 40, height: 60 };
    println!("can rect1 hold rect2 = {}", rect1.can_hold(&rect2));
    println!("can rect2 hold rect1 = {}", rect2.can_hold(&rect1));
    /*
    can rect1 hold rect2 = false
    can rect2 hold rect1 = true
    */

}

为我们想要定义的是方法,所以我们会把新添加的代码放置到 impl Rectangle 块中;另外,这个名为 can_hold 的方法需要接收另一个 Rectangle 的引用作为参数,因为不能夺走所有权,所以需要传递引用。

最终 can_hold 方法中会比较一个矩形的长和宽是否均大于另一个矩形,是的话返回 true,否则返回 false。


关联函数

除了方法,impl 块还允许我们定义不用接收 self 作为参数的函数,由于这类函数与结构体相互关联,所以它们也被称为关联函数(associated function)。我们将其命名为函数而不是方法,是因为它们不会作用于某个具体的结构体实例,我们曾经接触过的 String::from 就是关联函数的一种。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32
}

impl Rectangle {
    fn get_area(&self) -> u32 {
        self.width * self.height
    }
    fn initial(width: u32, height: u32) -> Rectangle {
        // 关联函数不涉及任何的结构体实例
        // 因为它里面没有所谓的 self
        Rectangle{width, height}
    }
}

fn main() {
    // 然后我们在使用的时候,通过结构体本身来调用,而不是实例
    // 实例调用的是方法,而结构体、或者说类型本身调用的是关联函数
    // 而类型调用关联函数的话,要通过 :: 来调用,而实例则是通过 . 来调用
    let rect = Rectangle::initial(4070);
    println!("rect = {:?}", rect);
    /*
    rect = Rectangle { width: 40, height: 70 }
    */


    // 所以关联函数经常用作构造器来返回相应的结构体实例
    // 这里可能有人好奇了,通过类型调可以用关联函数
    // 那么能不能调用方法呢?答案是可以的
    println!("area = {}", rect.get_area());
    println!("area = {}", Rectangle::get_area(&rect));
    /*
    area = 2800
    area = 2800
    */


    let rect2 = Rectangle{width: 20, height: 30};
    let rect3 = Rectangle{width: 30, height: 40};
    let rect4 = Rectangle{width: 40, height: 50};
    println!("rect2 area = {}", rect2.get_area());
    println!("rect3 area = {}", rect3.get_area());
    println!("rect4 area = {}", rect4.get_area());
    /*
    rect2 area = 600
    rect3 area = 1200
    rect4 area = 2000
    */


    println!("rect2 area = {}", Rectangle::get_area(&rect2));
    println!("rect3 area = {}", Rectangle::get_area(&rect3));
    println!("rect4 area = {}", Rectangle::get_area(&rect4));
    /*
    rect2 area = 600
    rect3 area = 1200
    rect4 area = 2000
    */

}

有 Python 经验的人应该会很熟悉这种机制,因为在 Python 里面也是如此:自动传递第一个参数的实例调用,等价于手动传递第一个参数的类型调用。

class A:
    
    def test(self):
        pass
    
a = A()
# 以下两者是等价的
# a.test() 实例调用,会自动将自身作为参数传递给 self
# A.test() 类型调用,需要手动传递实例进去
a.test()  
A.test(a)

Rust 和 Python 在这一点上是比较像的,只不过还是有两个细微差别:

  • 在 Rust 中,实例调用时使用的操作符是 . 而类型调用时使用的操作符是 ::

  • 在 Rust 中,实例调用会自动传递其引用,因为方法里面的 self 是一个引用;而类型调用时需要手动传递,那么此时就没有自动转换了,我们要手动通过 & 获取引用之后再传递。同理,如果方法里的 self 是可变引用,那么实例调用也依旧会自动传递可变引用;而类型调用则需要我们手动通过 &mut 获取可变引用之后,再进行传递

当然了,与其说这里像 Python,不如说更像 C++。然后是关联函数,相信此时能更好地理解了,因为它没有 self 参数,所以直接通过类型调用即可,而通过实例调用的话反而会出问题。因此关联函数一般用作实例的构造器,我们用的 String::from 就是专门负责构造 String 对象。


多个 impl 块

每个结构体可以拥有多个 impl 块,下面的例子中将方法和关联函数放置到了不同的 impl 块里。

impl Rectangle {
    fn get_area(&self) -> u32 {
        self.width * self.height
    }
}

impl Rectangle {
    fn initial(width: u32, height: u32) -> Rectangle {
        Rectangle{width, height}
    }
}

每个结构体的 impl 块的数量不限,不同的方法(关联函数)可以放在不同的块里面。虽然这里没有采用多个 impl 块的必要,但它是合法的,我们会在后续讨论泛型和 trait 时看到多个 impl 块的实际应用场景。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多