Rust 学习笔记(卷二)

Rust 学习笔记(卷二)

八、工程

相比以前的 C++,Rust 提出了包和模块的概念,使工程管理变得更加有组织。下面我们会自顶向下的介绍 Rust 中有关工程的概念。

1. package 和 crate

package 总览

Rust 工程管理中,最大的概念是 package,其次是 crate。Rust 的 crate 可以是一个库,也可以是一个可执行文件,而 package 的作用则是将一个或多个 crate 组织起来。

Package 是使用 Cargo.toml 文件管理的,这是我们所熟悉的,而 crate 则有组织地被放在项目文件夹中,包括:

  1. 库包。需要存在 src/lib.rs 源文件。包名与项目名相同。一个项目只能有一个库包。
  2. 可执行文件包。需要存在 src/main.rs 源文件。包名与项目名相同。
  3. 更多可执行文件包。每个 src/bin/ 中的源文件都是一个包,包名与文件名相同。

对比 C++ 来看,package 可以看作 CMake 的 project,而 crate 可以看作 CMake 的 target。

包根(crate root)

前面提到,如果一个 package 包含唯一的库包0,则需要存在 src/lib.rs 文件;对于可执行文件包则需要存在 src/main.rs。像这种一个源文件代表一个 crate 的,我们称为包根(crate root)。

包根只是一个源文件,那多个源文件该怎么办?方法是借助模块。模块的机制允许我们将包内的代码放在多个源文件中。

2. 模块

初识模块

Rust 中,与模块相关的关键字是 mod

Rust 程序 79:mod
// src/orange.rs
pub fn hello() {
    println!("Hello, orange!");
}
// src/main.rs
mod orange;
use orange::hello;
fn main() {
    hello();
}
C++ 程序 79:module
// orange.cpp
export module orange; // 手动写明,与文件名本身无关。
import std;
namespace orange { // 手动创建命名空间,与模块无关。
	export void hello() {
		std::cout << "Hello, orange!" << std::endl;
	}
}
// 源.cpp
import orange;
using orange::hello;
int main() {
	hello();
}

关于 Rust 程序 79 的新东西有:

  1. pub 关键字。如果要使用的东西不属于自己的父模块(例如程序 79 中,orange 模块和 main 模块是兄弟,orange 模块不是 main 模块的父模块),则只能使用公开的模块,用 pub 关键字表示。这和 C++ 程序 79 中 hello 函数前的 export 关键字一样。
  2. mod 关键字。其功能是在包根所在目录查找指定名字的源文件,并将源文件中的内容视为位于同名模块中(例如程序 79 中,在包根 main.rs 同目录下找到了 orange.rs,将 orange.rs 中的内容视为位于模块 orange 中)。
  3. use 关键字。一旦模块进入视野内(例如程序 79 中通过 mod 语句在当前位置声明了一个模块,又如通过配置依赖项已经引入了外部模块),则可以使用 use 语句引入模块里的内容。使用 use 语句引入内容的可见性将在之后讲解。

与 C++ 要求必须在单独的文件里定义模块不同,Rust 中允许在同一个源文件里定义模块。所以 Rust 的模块更像是 C++ 的模块和命名空间的结合体。

Rust 程序 80:只有包根
mod orange {
    pub fn hello() {
        println!("Hello, orange!");
    }
}
use orange::hello;
fn main() {
    hello();
}
C++ 程序 80:假装是模块
import std;
namespace orange { // 只有命名空间,没有模块。
	void hello() {
		std::cout << "Hello, orange!" << std::endl;
	}
}
using orange::hello;
int main() {
	hello();
}

Rust 程序 80 中,mod 块前没有再加 pub,这是因为我们在下面使用它时已经能够看到它的完整实现了,所以没有必要再加上 pub

Rust 中,为什么不像 C++ 那样必须写 import std;?因为 Rust 已经帮我们把常用的模块导入,模块名为 std::prelude

单个源文件中的嵌套模块

如前所述,Rust 的模块就像 C++ 中模块和命名空间的组合。在单个源文件中,模块就可以嵌套定义,形成树形结构。

Rust 程序 81:模块树
// 模块路径为 crate::orange。使用 crate 表示根模块,对应包根。
mod orange {
    pub struct Demo {
        pub name: String, // 模块外只能访问结构体的 pub 字段。
    }

    // 模块路径为 crate::orange::details。
    mod details {
        // 使用 super 表示模块树中的父模块。可省略。
        impl super::Demo {
            pub fn print(&self) {
                println!("{}", self.name);
            }
        }
    }
}

// main 的模块路径为 crate::main。
fn main() {
    // 使用 self 表示当前模块。只能使用 pub 的内容。
    use self::orange::Demo;
    let demo = Demo {
        name: String::from("Orange"),
    };
    demo.print(); // 只需让实现的方法为 pub,无需让 impl 的模块为 pub。
}
C++ 程序 81:命名空间树
import std;
// 命名空间路径为 ::orange。使用 :: 表示全局命名空间。
namespace orange {
	struct Demo {
	public: // 类外只能访问结构体的 public 成员。
		std::string name;
	};
	// 命名空间路径为 ::orange::details。
	namespace details {
		// 父命名空间可省略。
		void print(const Demo& self) {
			std::cout << std::format("{}", self.name) << std::endl;
		}
	}
}

// main 的命名空间路径为 ::main。
int main() {
	using orange::Demo;
	const auto demo = Demo{
		.name = std::string("Orange"),
	};
	// C++ 不支持在多个命名空间内实现类的扩展方法。
	using namespace orange::details;
	print(demo);
}

具有层级结构的源文件形成的嵌套模块

如前所述,创建除包根外的源文件会导致新建一个模块。例如,包根 src/main.rs 对应的模块是 crate,而与包根位于同一目录的 src/another.rs 对应的模块则是 crate::another。这是我们在程序 79 中已经学会的。但如果我们希望用一个单独的源文件表示模块 crate::another::yet_another,该怎么做?

方法是创建文件夹。容易想到,可以在 src/ 新建一个名为 another 的文件夹,再在 another 文件夹中新建 yet_another.rs 源文件。

src/
|--main.rs
|--another/
   |--yet_another.rs

但这是不够的。Rust 规定,对于 another 这种用文件夹表示的层级子模块,需要在文件夹中新建名为 mod.rs 的源文件,并在其中显式的导入其中的子模块。所以合规的文件结构应该为:

src/
|--main.rs
|--another/
   |--mod.rs          # 每个子文件夹中都必须有 mod.rs。
   |--yet_another.rs

亦或者,在文件夹外新建一个与文件夹同名的源文件。

src/
|--main.rs
|--another.rs        # 或者把 mod.rs 换成与模块同名的源文件放在外面。
|--another/
   |--yet_another.rs

以上两种方式只能取其一。下面以 mod.rs 为例。mod.rs 的内容应该为:

pub mod yet_another;

main.rs 的开头还应该有:

mod another; // 包根 main.rs 不需要导出模块,所以不加 pub。

在完成以上准备工作后,要在 main.rs 中使用 yet_another 中的内容,可以写:

use another::yet_another::*; // ::* 表示导入所有内容。

为什么需要写以上 mod 语句和 use 语句?下面我们总结一下 Rust 从包根开始搜索模块的过程。

  1. Rust 编译器只知道存在包根(main.rslib.rs)。包根对应的模块为根模块 crate
  2. 如果包根源文件不使用 mod 语句声明模块,则编译器不知道存在其他模块。
  3. 使用 mod 语句引入模块 yet_another,编译器发现存在文件夹 another,于是编译 src/another/mod.rssrc/another.rs,如果同时存在则报错。
  4. 编译 src/another/mod.rs 时,检查到 pub mod yet_another,编译器发现不存在文件夹 src/another/yet_another,但存在 src/another/yet_another.rs,于是将该源文件作为模块 crate::another::yet_another 编译。
  5. main.rs 中,已经能看到 crate::another。由于 yet_anotherpub 的,所以也能看到模块 crate::another::yet_another。使用 use 语句简化其中内容的使用。

一个很现实的问题是,子模块有时希望导出自己导入的内容(例如 another 模块希望导出 another::yet_another 模块),有时又希望不导出导入的内容,仅仅在内部自己使用(例如 crate 模块不会导出 crate::another 模块)。显然我们可以用 pub 关键字控制这一点。不过,上面的例子的导出对象都是模块,如果我们仅仅想要导出一个已经实现的函数该怎么办?可以使用 pub use 语句将当前模块导入的内容再导出。外部使用时,看这个导出的内容就好像是该模块自己实现的一样。

Rust 程序 82:引入项再导出
// orange.rs
mod utils {
    pub fn print() {
        println!("Orange");
    }
    // 可见,但限制只能在父模块中可见。
    pub(super) fn internal_print() {
        println!("Orange is handsome.");
    }
}

// pub use 语句只能再导出 pub 的内容。
pub use utils::print; // 通过 use 语句实现了导出。

pub fn orange_main() {
    print(); // use 语句的效果。
    utils::internal_print();
}

// main.rs
mod orange;
fn main() {
    orange::orange_main();
    orange::print(); // pub use 语句的效果。
}
C++ 程序 82:引入项再导出
// orange.ixx
export module orange;
import std;

namespace orange {
	namespace utils {
		void print() {
			std::cout << "Orange" << std::endl;
		}
		void internal_print() {
			std::cout << "Orange is handsome." << std::endl;
		}
	}

	// export using 语句可以直接导出非 export 的内容。
	export using utils::print;

	export void orange_main() {
		print(); // using 语句的效果。
		utils::internal_print();
	}
}

// main.cpp
import orange;
int main() {
	orange::orange_main();
	orange::print(); // export using 语句的效果。
}

小结 use 语句

最后,我们对 use 语句作一些补充,并作一个简单的小结。可以说 Rust 中的 use 语句几乎等价于:

  1. C++ 中的 using 语句(后接函数等)和 using namespace 语句(后接命名空间)的结合。
  2. C# 中的 using static 语句(后接函数等)和 using 语句(后接命名空间)的结合。
  3. Java 中的 import 语句(后接函数或包)。
  4. ……

但 Rust 与 C++ 更类似,在使用 use 语句前,必须能够看到希望使用的内容。Rust 中使用 mod 语句从包根开始声明自己希望使用的模块,而 C++ 使用 import 语句声明自己希望使用的模块。相比之下,C#、Java 等语言不需要用额外语句导入模块。

很显然,use 语句能够帮助我们省略冗长的包路径,是一个缩写助手。所以 use 语句也能为所使用的内容起别名。

Rust 程序 83:使用别名
fn main() {
    use std::print as my_print;
    my_print!("Hello, world!");
    use std as my_std;
    my_std::path::Path::new("src/main.rs");
}
C++ 程序 83:使用别名
import std;
int main() {
	// C++ 不允许用 using 语句为函数或对象起别名。
	auto& my_cout = std::cout;
	my_cout << "Hello, world!" << std::endl;
	// C++ 中使用 namespace 语句为命名空间起别名。
	namespace my_std = std;
	my_std::filesystem::path("源.cpp");
}

3. 文档

4. 使用第三方包

5. 打包自己的包

九、标准库

十、多线程的并发编程

并发、异步。

十一、“不安全”编程

unsafe 代码块

全局变量与静态变量

理论上,全局变量越少越好,以防形成一盘散沙之势。然而,全局变量通常也是无法避免的,例如在使用单例设计模式时。

内部可变性