【跟小嘉学 Rust 编程】三十二、Rust的设计模式(Design Patterns)

系列文章目录

【跟小嘉学 Rust 编程】一、Rust 编程基础
【跟小嘉学 Rust 编程】二、Rust 包管理工具使用
【跟小嘉学 Rust 编程】三、Rust 的基本程序概念
【跟小嘉学 Rust 编程】四、理解 Rust 的所有权概念
【跟小嘉学 Rust 编程】五、使用结构体关联结构化数据
【跟小嘉学 Rust 编程】六、枚举和模式匹配
【跟小嘉学 Rust 编程】七、使用包(Packages)、单元包(Crates)和模块(Module)来管理项目
【跟小嘉学 Rust 编程】八、常见的集合
【跟小嘉学 Rust 编程】九、错误处理(Error Handling)
【跟小嘉学 Rust 编程】十一、编写自动化测试
【跟小嘉学 Rust 编程】十二、构建一个命令行程序
【跟小嘉学 Rust 编程】十三、函数式语言特性:迭代器和闭包
【跟小嘉学 Rust 编程】十四、关于 Cargo 和 Crates.io
【跟小嘉学 Rust 编程】十五、智能指针(Smart Point)
【跟小嘉学 Rust 编程】十六、无畏并发(Fearless Concurrency)
【跟小嘉学 Rust 编程】十七、面向对象语言特性
【跟小嘉学 Rust 编程】十八、模式匹配(Patterns and Matching)
【跟小嘉学 Rust 编程】十九、高级特性
【跟小嘉学 Rust 编程】二十、进阶扩展
【跟小嘉学 Rust 编程】二十一、网络编程
【跟小嘉学 Rust 编程】二十三、Cargo 使用指南
【跟小嘉学 Rust 编程】二十四、内联汇编(inline assembly)
【跟小嘉学 Rust 编程】二十五、Rust命令行参数解析库(clap)
【跟小嘉学 Rust 编程】二十六、Rust的序列化解决方案(Serde)
【跟小嘉学 Rust 编程】二十七、Rust 异步编程(Asynchronous Programming)
【跟小嘉学 Rust 编程】二十八、Rust中的日期与时间
【跟小嘉学 Rust 编程】二十九、Rust 中的零拷贝序列化解决方案(rkyv)
【跟小嘉学 Rust 编程】三十、Rust 使用 Slint UI
【跟小嘉学 Rust 编程】三十一、Rust的日志与追踪
【跟小嘉学 Rust 编程】三十二、Rust的设计模式(Design Patterns)

文章目录

  • 系列文章目录
    • @[TOC](文章目录)
  • 前言
  • 一、惯常做法
    • 1.1、使用借用类型作为参数
    • 1.2、使用 format! 拼接字符串
    • 1.3、构造器
      • 1.3.1、关联函数 new
      • 1.3.2、默认构造器
    • 1.4、集合是智能指针
    • 1.5、析构器中的最终处理
    • 1.6、使用 take 和 replace 来保留所有值
    • 1.7、栈上动态分配
    • 1.8、外部函数接口(FFI)
      • 1.8.1、错误处理的惯常做法
      • 1.8.2、接受字符串
      • 1.8.3、传递字符串
    • 1.9、Option 的迭代
    • 1.10、传递变量到闭包
    • 1.11、可扩展性
      • 1.11.1、#[non_exhaustive]
      • 1.11.2、私有字段的方式
    • 1.12、简单的文档初始化
    • 1.13、临时可变性
  • 二、设计模式
    • 2.1、行为型
      • 2.1.1、命令
      • 2.1.2、解释型
      • 2.1.3、新类型(Newtype)
      • 2.1.4、RAII守护对象
      • 2.1.5、策略
      • 2.1.6、访问器
    • 2.2、建造型
      • 2.2.1、生成器
      • 2.2.2、Fold
    • 2.3、结构型
      • 2.3.1、组合结构体
      • 2.3.2、倾向于较小的Crates
      • 2.3.3、把不安全因素放在小模块中
    • 2.4、外部函数接口(FFI)
      • 2.4.1、基于对象的API
      • 2.4.2、类型合并
  • 三、反面模式
    • 3.1、通过 Clone 来满足借用检查器
    • 3.2、#[deny(warnings)]
    • 3.3、解引用多态性

前言

Rust不是面向对象的,设计模式与其他面向对象的编程语言不同,虽然细节不同,但是和具有相同的形式

  • 设计模式:是解决编写软件时常见问题的方法。
  • 反面模式:是解决这些相同的常见问题的方法。然而,在设计模式给我们带来好处的同时,反面模式却带来了更多的问题。
  • 惯常做法:是编码时要遵循的准则。 它们是社区的社会规范。 你可以打破它们,但如果你这样做,你应该有一个好的理由。

主要教材参考 《The Rust Programming Language》
主要教材参考 《Rust For Rustaceans》
主要教材参考 《The Rustonomicon》
主要教材参考 《Rust 高级编程》
主要教材参考 《Cargo 指南》
主要教材参考 《Rust 异步编程》
主要教材参考 《Rust 设计模式》


一、惯常做法

1.1、使用借用类型作为参数

编码时应该总是倾向于使用借用类型而不是借用所有类型。例如 使用&str 而不是&string&[T]而不是&Vec,以及&T而不是&Box

使用借用类型可以避免已经提供一层间接性的所有类型上的多层间接。

fn three_vowels(word: &String) -> bool {
    let mut vowel_count = 0;
    for c in word.chars() {
        match c {
            'a' | 'e' | 'i' | 'o' | 'u' => {
                vowel_count += 1;
                if vowel_count >= 3 {
                    return true
                }
            }
            _ => vowel_count = 0
        }
    }
    false
}

fn main() {
    let ferris = "Ferris".to_string();
    let curious = "Curious".to_string();
    println!("{}: {}", ferris, three_vowels(&ferris));
    println!("{}: {}", curious, three_vowels(&curious));

    // This works fine, but the following two lines would fail:
    // println!("Ferris: {}", three_vowels("Ferris"));
    // println!("Curious: {}", three_vowels("Curious"));
}

此时,我们如果放开最后两行的注释,将会失败,我们可以修改声明

fn three_vowels(word: &str) -> bool 

此时两种版本都不会报错

1.2、使用 format! 拼接字符串

在可变的 string 上使用 push 和 push_str 方法来建立字符串,或使用 + 操作符。但是使用 format! 往往会更加方便,特别是在有字面和非字面字符串混合使用的时候。

fn say_hello(name: &str) -> String {
    // We could construct the result string manually.
    // let mut result = "Hello ".to_owned();
    // result.push_str(name);
    // result.push('!');
    // result

    // But using format! is better.
    format!("Hello {}!", name)
}

1.3、构造器

1.3.1、关联函数 new

Rust 没有构造器作为语言的构造,惯常是使用一个关联函数 new 来创建对象

pub struct Second {
    value: u64
}

impl Second {
    // Constructs a new instance of [`Second`].
    // Note this is an associated function - no self.
    pub fn new(value: u64) -> Self {
        Self { value }
    }

    /// Returns the value in seconds.
    pub fn value(&self) -> u64 {
        self.value
    }
}

1.3.2、默认构造器

Rust 通过 Default trait 来支持默认构造器

pub struct Second {
    value: u64
}

impl Second {
    /// Returns the value in seconds.
    pub fn value(&self) -> u64 {
        self.value
    }
}

impl Default for Second {
    fn default() -> Self {
        Self { value: 0 }
    }
}

我们可以使用 derive 来做默认实现

#[derive(Default)]
pub struct Second {
    value: u64
}

impl Second {
    /// Returns the value in seconds.
    pub fn value(&self) -> u64 {
        self.value
    }
}

1.4、集合是智能指针

使用 Deref 特征将集合视为智能指针,提供拥有和借用的数据视图。

use std::ops::Deref;

struct Vec<T> {
    data: RawVec<T>,
    //..
}

impl<T> Deref for Vec<T> {
    type Target = [T];

    fn deref(&self) -> &[T] {
        //..
    }
}

所有权和借用是 Rust 语言的关键方面,数据结构必须正确说明这些语义,以便提供良好的用户体验。

1.5、析构器中的最终处理

Rust 没有提供与 finally 类似的做法:无论函数如何退出都会被执行,相反,一个对象的析构器可以被用来运行必须在退出前运行的代码。

#[derive(Debug)]
struct A(u8);
impl Drop for A {
    fn drop(&mut self) {
        println!("A exit");
    }
}

如果函数中存在无限循环或运行函数退出前奔溃,则不会drop函数。

1.6、使用 take 和 replace 来保留所有值

例子:

use std::mem;

enum MyEnum {
    A { name: String, x: u8 },
    B { name: String }
}

fn a_to_b(e: &mut MyEnum) {
    if let MyEnum::A { name, x: 0 } = e {
        // this takes out our `name` and put in an empty String instead
        // (note that empty strings don't allocate).
        // Then, construct the new enum variant (which will
        // be assigned to `*e`).
        *e = MyEnum::B { name: mem::take(name) }
    }
}

使用std::mem::take()和std::mem::replace()在不克隆name的情况下修改name。

1.7、栈上动态分配

Rust 的 trait 对象是动态分发的。Rust 默认会对代码进行单态处理,这意味着每一种类型的代码都会被生成一个副本,并且被独立优化。虽然这允许在热点路径上产生非常快的代码,但它也会在性能不重要的地方使代码变得臃肿,从而耗费编译时间和缓存使用量。

幸运的是,Rust允许我们使用动态分发,但我们必须明确要求它。

use std::io;
use std::fs;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let arg = "-";

    // These must live longer than `readable`, and thus are declared first:
    let (mut stdin_read, mut file_read);

    // We need to ascribe the type to get dynamic dispatch.
    let readable: &mut dyn io::Read = if arg == "-" {
        stdin_read = io::stdin();
        &mut stdin_read
    } else {
        file_read = fs::File::open(arg)?;
        &mut file_read
    };

    // Read from `readable` here.

    Ok(())
}

1.8、外部函数接口(FFI)

1.8.1、错误处理的惯常做法

在C语言等外部语言中,错误是由返回码来表示的,然而 Rust 的 类型系统允许通过一个完整的类型来捕获和传播更丰富的错误信息。

这个最佳实践展示了不同种类的错误代码,以及如何以一种可用的方式暴露它们:

  • 简单枚举应该被转换为整数,并作为代码返回。
  • 结构化的枚举应该被转换为整数代码,并有一个字符串错误消息作为细节。
  • 自定义错误类型应该变得”透明“,用C表示。

1、简单的枚举

enum DatabaseError {
    IsReadOnly = 1, // user attempted a write operation
    IOError = 2, // user should read the C errno() for what it was
    FileCorrupted = 3, // user should run a repair tool to recover it
}

impl From<DatabaseError> for libc::c_int {
    fn from(e: DatabaseError) -> libc::c_int {
        (e as i8).into()
    }
}

2、结构化枚举

pub mod errors {
    enum DatabaseError {
        IsReadOnly,
        IOError(std::io::Error),
        FileCorrupted(String), // message describing the issue
    }

    impl From<DatabaseError> for libc::c_int {
        fn from(e: DatabaseError) -> libc::c_int {
            match e {
                DatabaseError::IsReadOnly => 1,
                DatabaseError::IOError(_) => 2,
                DatabaseError::FileCorrupted(_) => 3,
            }
        }
    }
}

pub mod c_api {
    use super::errors::DatabaseError;

    #[no_mangle]
    pub extern "C" fn db_error_description(
        e: *const DatabaseError
        ) -> *mut libc::c_char {

        let error: &DatabaseError = unsafe {
            // SAFETY: pointer lifetime is greater than the current stack frame
            &*e
        };

        let error_str: String = match error {
            DatabaseError::IsReadOnly => {
                format!("cannot write to read-only database");
            }
            DatabaseError::IOError(e) => {
                format!("I/O Error: {}", e);
            }
            DatabaseError::FileCorrupted(s) => {
                format!("File corrupted, run repair: {}", &s);
            }
        };

        let c_error = unsafe {
            // SAFETY: copying error_str to an allocated buffer with a NUL
            // character at the end
            let mut malloc: *mut u8 = libc::malloc(error_str.len() + 1) as *mut _;

            if malloc.is_null() {
                return std::ptr::null_mut();
            }

            let src = error_str.as_bytes().as_ptr();

            std::ptr::copy_nonoverlapping(src, malloc, error_str.len());

            std::ptr::write(malloc.add(error_str.len()), 0);

            malloc as *mut libc::c_char
        };

        c_error
    }
}

3、自定义错误类型

struct ParseError {
    expected: char,
    line: u32,
    ch: u16
}

impl ParseError { /* ... */ }

/* Create a second version which is exposed as a C structure */
#[repr(C)]
pub struct parse_error {
    pub expected: libc::c_char,
    pub line: u32,
    pub ch: u16
}

impl From<ParseError> for parse_error {
    fn from(e: ParseError) -> parse_error {
        let ParseError { expected, line, ch } = e;
        parse_error { expected, line, ch }
    }
}

这就保证了外部语言可以清楚地获得错误信息,同时完全不影响Rust代码的API。

1.8.2、接受字符串

当 FFI 通过指针接受字符串,应该遵循两个原则

  • 保持外部字符串是借用的,而不是直接复制他们
  • 进来减少从C风格字符串转换到原生 Rust 字符串时涉及到复杂性和 unsafe 代码量

C语言中使用的字符串与Rust语言中使用的字符串有不同的行为:

  • C语言的字符串是无终止的,而Rust语言的字符串会存储其长度。
  • C语言的字符串可以包含任何任意的非零字节,而Rust的字符串必须是UTF-8。
  • C语言的字符串使用unsafe的指针操作来访问和操作,而与Rust字符串的交互是通过安全方法进行的。

Rust标准库提供了与Rust的String和&str相对应的C语言等价表示,称为CString和&CStr,这使得我们可以避免在C语言字符串和Rust字符串之间转换的复杂性和unsafe代码。

&CStr类型还允许我们使用借用数据,这意味着在Rust和C之间传递字符串是一个零成本的操作。

pub mod unsafe_module {
    #[no_mangle]
    pub unsafe extern "C" fn mylib_log(
        msg: *const libc::c_char,
        level: libc::c_int
    ) {
        let level: crate::LogLevel = match level { /* ... */ };

        // SAFETY: The caller has already guaranteed this is okay (see the
        // `# Safety` section of the doc-comment).
        let msg_str: &str = match std::ffi::CStr::from_ptr(msg).to_str() {
            Ok(s) => s,
            Err(e) => {
                crate::log_error("FFI string conversion failed");
                return;
            }
        };

        crate::log(msg_str, level);
    }
}

优势:
1、unsafe块尽可能少
2、具有未跟踪的生命周期的指针成为跟踪的共享引用

1.8.3、传递字符串

当向 FFI 函数传递字符串时候应该遵守四个原则

  • 使拥有的字符串的生命周期尽可能长。
  • 在转换过程中尽量减少unsafe代码。
  • 如果C代码可以修改字符串数据,使用Vec而不是CString。
  • 除非外部函数API要求,否则字符串的所有权不应该转移给被调用者。

Rust内置了对C风格字符串的支持,有CString和CStr类型。 然而,对于从Rust函数中发送字符串到外部函数调用,我们可以采取不同的方法。

最好的做法很简单:用CString的方式来减少unsafe的代码。 然而,次要的注意事项是,对象必须活得足够长,这意味着生命周期应该最大化。 此外,文档解释说,CString进行"round-tripping"修改是未定义行为,所以在这种情况下需要额外的工作。

pub mod unsafe_module {

    // other module content

    extern "C" {
        fn seterr(message: *const libc::c_char);
        fn geterr(buffer: *mut libc::c_char, size: libc::c_int) -> libc::c_int;
    }

    fn report_error_to_ffi<S: Into<String>>(
        err: S
    ) -> Result<(), std::ffi::NulError>{
        let c_err = std::ffi::CString::new(err.into())?;

        unsafe {
            // SAFETY: calling an FFI whose documentation says the pointer is
            // const, so no modification should occur
            seterr(c_err.as_ptr());
        }

        Ok(())
        // The lifetime of c_err continues until here
    }

    fn get_error_from_ffi() -> Result<String, std::ffi::IntoStringError> {
        let mut buffer = vec![0u8; 1024];
        unsafe {
            // SAFETY: calling an FFI whose documentation implies
            // that the input need only live as long as the call
            let written: usize = geterr(buffer.as_mut_ptr(), 1023).into();

            buffer.truncate(written + 1);
        }

        std::ffi::CString::new(buffer).unwrap().into_string()
    }
}

  • unsafe块尽可能小。
  • CString存活得足够久。
  • 类型转换的错误被尽可能传播。

1.9、Option 的迭代

由于 Option 可以被看作包含零或一个元素的容器,特别是,它实现了 IntoIterator 特征。


#![allow(unused)]
fn main() {
	let turing = Some("Turing");
	let logicians = vec!["Curry", "Kleene", "Markov"];

	for logician in logicians.iter().chain(turing.iter()) {
	    println!("{} is a logician", logician);
	}
}

1.10、传递变量到闭包

默认情况下,闭包通过借用捕获其环境,在将变量转移到闭包中时,在单独的作用域中使用变量重绑定。


#![allow(unused)]
fn main() {
use std::rc::Rc;

let num1 = Rc::new(1);
let num2 = Rc::new(2);
let num3 = Rc::new(3);
let closure = {
    // `num1` is moved
    let num2 = num2.clone();  // `num2` is cloned
    let num3 = num3.as_ref();  // `num3` is borrowed
    move || {
        *num1 + *num2 + *num3;
    }
};
}

1.11、可扩展性

在一小部分情况下,库作者想在不破坏向后兼容性的情况下,为公共结构体添加公共字段或为枚举添加新的变体。

Rust 为这个问题提供了两种解决方案

  • 在struct,enum和enum变体上使用#[non_exhaustive]。
  • 你可以向结构体添加一个私有字段,以防止它被直接实例化或与之匹配

1.11.1、#[non_exhaustive]


#[non_exhaustive]
pub struct StudentInfo {
    pub name: String,
    pub age: u32,
    pub number: u32,
}

在同一个 crate 使用该结构体和没加 non_exhaustive 一样,外部要使用该结构体需要通过我们提供的函数来创建。

1.11.2、私有字段的方式

不允许外部 crate 直接创建

pub struct StudentInfo {
    pub name: String,
    pub age: u32,
    pub number: u32,
    _b: (), //添加一个私有成员
}

1.12、简单的文档初始化

关于编写文档的简化规则

struct Connection {
    name: String,
    stream: TcpStream,
}

impl Connection {
    /// Sends a request over the connection.
    ///
    /// # Example
    /// ```
    /// # fn call_send(connection: Connection, request: Request) {
    /// let response = connection.send_request(request);
    /// assert!(response.is_ok());
    /// # }
    /// ```
    fn send_request(&self, request: Request) {
        // ...
    }
} 

1.13、临时可变性

通常在准备和处理一些数据后,数据只是被检查,而不会被修改。对于临时可变的变量,在可变之后进行重绑定来明确为不可变的变量。


fn main() {
    let mut data = vec![2, 1, 4, 10, 3, 5];
    data.sort();
    let data = data; // 进行重新绑定,data变为不可变的变量

    println!("{:?}", data[2]);
   
    // data.push(4); // error, data is immutable

    // 也可以使用如下使用嵌套块,和上面等价
    let data = {
        let mut data = vec![2, 1, 4, 10, 3, 5];
        data.sort();
        data
    };
    println!("{:?}", data[2]);
    // data.push(4); // error, data is immutable
}

二、设计模式

2.1、行为型

识别对象间常见通信模式的设计模式,这样做增加了进行通信的灵活性。

2.1.1、命令

命令模式的基本思想就是将行动分离成它自己的对象,并将他们作为参数传递。

1、使用公共 trait

例如

pub trait Migration {
    fn execute(&self) -> &str;
    fn rollback(&self) -> &str;
}

pub struct CreateTable;
impl Migration for CreateTable {
    fn execute(&self) -> &str {
        "create table"
    }
    fn rollback(&self) -> &str {
        "drop table"
    }
}

pub struct AddField;
impl Migration for AddField {
    fn execute(&self) -> &str {
        "add field"
    }
    fn rollback(&self) -> &str {
        "remove field"
    }
}

struct Schema {
    commands: Vec<Box<dyn Migration>>,
}

impl Schema {
    fn new() -> Self {
        Self { commands: vec![] }
    }

    fn add_migration(&mut self, cmd: Box<dyn Migration>) {
        self.commands.push(cmd);
    }

    fn execute(&self) -> Vec<&str> {
        self.commands.iter().map(|cmd| cmd.execute()).collect()
    }
    fn rollback(&self) -> Vec<&str> {
        self.commands
            .iter()
            .rev() // reverse iterator's direction
            .map(|cmd| cmd.rollback())
            .collect()
    }
}

fn main() {
    let mut schema = Schema::new();

    let cmd = Box::new(CreateTable);
    schema.add_migration(cmd);
    let cmd = Box::new(AddField);
    schema.add_migration(cmd);

    assert_eq!(vec!["create table", "add field"], schema.execute());
    assert_eq!(vec!["remove field", "drop table"], schema.rollback());
}

2、使用函数指针

type FnPtr = fn() -> String;
struct Command {
    execute: FnPtr,
    rollback: FnPtr,
}

struct Schema {
    commands: Vec<Command>,
}

impl Schema {
    fn new() -> Self {
        Self { commands: vec![] }
    }
    fn add_migration(&mut self, execute: FnPtr, rollback: FnPtr) {
        self.commands.push(Command { execute, rollback });
    }
    fn execute(&self) -> Vec<String> {
        self.commands.iter().map(|cmd| (cmd.execute)()).collect()
    }
    fn rollback(&self) -> Vec<String> {
        self.commands
            .iter()
            .rev()
            .map(|cmd| (cmd.rollback)())
            .collect()
    }
}

fn add_field() -> String {
    "add field".to_string()
}

fn remove_field() -> String {
    "remove field".to_string()
}

fn main() {
    let mut schema = Schema::new();
    schema.add_migration(|| "create table".to_string(), || "drop table".to_string());
    schema.add_migration(add_field, remove_field);
    assert_eq!(vec!["create table", "add field"], schema.execute());
    assert_eq!(vec!["remove field", "drop table"], schema.rollback());
}

3、使用 Fn Trait 对象

type Migration<'a> = Box<dyn Fn() -> &'a str>;

struct Schema<'a> {
    executes: Vec<Migration<'a>>,
    rollbacks: Vec<Migration<'a>>,
}

impl<'a> Schema<'a> {
    fn new() -> Self {
        Self {
            executes: vec![],
            rollbacks: vec![],
        }
    }
    fn add_migration<E, R>(&mut self, execute: E, rollback: R)
    where
        E: Fn() -> &'a str + 'static,
        R: Fn() -> &'a str + 'static,
    {
        self.executes.push(Box::new(execute));
        self.rollbacks.push(Box::new(rollback));
    }
    fn execute(&self) -> Vec<&str> {
        self.executes.iter().map(|cmd| cmd()).collect()
    }
    fn rollback(&self) -> Vec<&str> {
        self.rollbacks.iter().rev().map(|cmd| cmd()).collect()
    }
}

fn add_field() -> &'static str {
    "add field"
}

fn remove_field() -> &'static str {
    "remove field"
}

fn main() {
    let mut schema = Schema::new();
    schema.add_migration(|| "create table", || "drop table");
    schema.add_migration(add_field, remove_field);
    assert_eq!(vec!["create table", "add field"], schema.execute());
    assert_eq!(vec!["remove field", "drop table"], schema.rollback());
}

2.1.2、解释型

解释器模式(Interpreter Pattern)提供了评估语言的语法或表达式的方式,它属于行为型模式。这种模式实现了一个表达式接口,该接口解释一个特定的上下文。这种模式被用在 SQL 解析、符号处理引擎等。

范例:逆波兰表示法

pub struct Interpreter<'a> {
    it: std::str::Chars<'a>,
}

impl<'a> Interpreter<'a> {

    pub fn new(infix: &'a str) -> Self {
        Self { it: infix.chars() }
    }

    fn next_char(&mut self) -> Option<char> {
        self.it.next()
    }

    pub fn interpret(&mut self, out: &mut String) {
        self.term(out);

        while let Some(op) = self.next_char() {
            if op == '+' || op == '-' {
                self.term(out);
                out.push(op);
            } else {
                panic!("Unexpected symbol '{}'", op);
            }
        }
    }

    fn term(&mut self, out: &mut String) {
        match self.next_char() {
            Some(ch) if ch.is_digit(10) => out.push(ch),
            Some(ch) => panic!("Unexpected symbol '{}'", ch),
            None => panic!("Unexpected end of string"),
        }
    }
}

pub fn main() {
    let mut intr = Interpreter::new("2+3");
    let mut postfix = String::new();
    intr.interpret(&mut postfix);
    assert_eq!(postfix, "23+");

    intr = Interpreter::new("1-2+3-4");
    postfix.clear();
    intr.interpret(&mut postfix);
    assert_eq!(postfix, "12-3+4-");
}

2.1.3、新类型(Newtype)

如果在某些情况下,我们希望一个类型的行为类似于另外一个类型,或者在编译时强制执行一些行为,仅仅使用类型别名是不够的。对于这种情况,我们可以使用 Newtype 模式来提供安全和封装。

// Some type, not necessarily in the same module or even crate.
struct Foo {
    //..
}

impl Foo {
    // These functions are not present on Bar.
    //..
}

// The newtype.
pub struct Bar(Foo);

impl Bar {
    // Constructor.
    pub fn new(
        //..
    ) -> Self {
        //..
    }

    //..
}

fn main() {
    let b = Bar::new(...);

    // Foo and Bar are type incompatible, the following do not type check.
    // let f: Foo = b;
    // let b: Bar = Foo { ... };
}

新类型的主要目的是抽象化,它允许你在类型之间共享实现细节,同时精确控制接口。通过使用新类型而不是实现类型作为API的一部分公开,它允许你向后兼容地改变实现。

被包装的类型和包装后的类型不是类型兼容的(相对于使用type),所以新类型的用户永远不会“混淆“包装前后的类型。

新类型是一个零成本的抽象——没有运行时的开销。

隐私系统确保用户无法访问被包装的类型(如果字段是私有的,默认情况下是私有的)。

2.1.4、RAII守护对象

RAII(Resource Acquisition is Initialisation),资源获取即初始化,该模式的本质就是资源初始化在对象构造器中完成,最终资源是释放在析构器中完成。

这种模式在Rust中得到了扩展,即使用RAII对象作为某些资源的守护对象,并依靠类型系统来确保访问总是由守护对象来调解。

use std::ops::Deref;

struct Foo {}

struct Mutex<T> {
    // We keep a reference to our data: T here.
    //..
}

struct MutexGuard<'a, T: 'a> {
    data: &'a T,
    //..
}

// Locking the mutex is explicit.
impl<T> Mutex<T> {
    fn lock(&self) -> MutexGuard<T> {
        // Lock the underlying OS mutex.
        //..

        // MutexGuard keeps a reference to self
        MutexGuard {
            data: self,
            //..
        }
    }
}

// Destructor for unlocking the mutex.
impl<'a, T> Drop for MutexGuard<'a, T> {
    fn drop(&mut self) {
        // Unlock the underlying OS mutex.
        //..
    }
}

// Implementing Deref means we can treat MutexGuard like a pointer to T.
impl<'a, T> Deref for MutexGuard<'a, T> {
    type Target = T;

    fn deref(&self) -> &T {
        self.data
    }
}

fn baz(x: Mutex<Foo>) {
    let xx = x.lock();
    xx.foo(); // foo is a method on Foo.
    // The borrow checker ensures we can't store a reference to the underlying
    // Foo which will outlive the guard xx.

    // x is unlocked when we exit this function and xx's destructor is executed.
}

有点:防止在资源没有最终处理和在最终处理后使用资源时出现错误。

RAII是一种有用的模式,可以确保资源被适当地取消分配或被最终处理。 我们可以利用Rust中的借用检查器来静态地防止在最终处理完成后使用资源所产生的错误。

2.1.5、策略

策略模式是一种实现关注点分离的技术,其基本思想就是给定一个特点问题的算法,只在抽象层面定义算法的骨架,将具体的算法分成的不同的部分。

use std::collections::HashMap;

type Data = HashMap<String, u32>;

trait Formatter {
    fn format(&self, data: &Data, buf: &mut String);
}

struct Report;

impl Report {
    // Write should be used but we kept it as String to ignore error handling
    fn generate<T: Formatter>(g: T, s: &mut String) {
        // backend operations...
        let mut data = HashMap::new();
        data.insert("one".to_string(), 1);
        data.insert("two".to_string(), 2);
        // generate report
        g.format(&data, s);
    }
}

struct Text;
impl Formatter for Text {
    fn format(&self, data: &Data, buf: &mut String) {
        for (k, v) in data {
            let entry = format!("{} {}\n", k, v);
            buf.push_str(&entry);
        }
    }
}

struct Json;
impl Formatter for Json {
    fn format(&self, data: &Data, buf: &mut String) {
        buf.push('[');
        for (k, v) in data.into_iter() {
            let entry = format!(r#"{{"{}":"{}"}}"#, k, v);
            buf.push_str(&entry);
            buf.push(',');
        }
        buf.pop(); // remove extra , at the end
        buf.push(']');
    }
}

fn main() {
    let mut s = String::from("");
    Report::generate(Text, &mut s);
    assert!(s.contains("one 1"));
    assert!(s.contains("two 2"));

    s.clear(); // reuse the same buffer
    Report::generate(Json, &mut s);
    assert!(s.contains(r#"{"one":"1"}"#));
    assert!(s.contains(r#"{"two":"2"}"#));
}

优点就是关注点分离。缺点就是每个策略至少有一个模块,所以模块的数量随着策略的数量增加。

2.1.6、访问器

访问器封装了一种在对象的异质集合上操作的算法。 它允许在同一数据上写入多种不同的算法,而不必修改数据(或其主要行为)。

此外,访问器模式允许将对象集合的遍历与对每个对象进行的操作分开。

// The data we will visit
mod ast {
    pub enum Stmt {
        Expr(Expr),
        Let(Name, Expr),
    }

    pub struct Name {
        value: String,
    }

    pub enum Expr {
        IntLit(i64),
        Add(Box<Expr>, Box<Expr>),
        Sub(Box<Expr>, Box<Expr>),
    }
}

// The abstract visitor
mod visit {
    use ast::*;

    pub trait Visitor<T> {
        fn visit_name(&mut self, n: &Name) -> T;
        fn visit_stmt(&mut self, s: &Stmt) -> T;
        fn visit_expr(&mut self, e: &Expr) -> T;
    }
}

use visit::*;
use ast::*;

// An example concrete implementation - walks the AST interpreting it as code.
struct Interpreter;
impl Visitor<i64> for Interpreter {
    fn visit_name(&mut self, n: &Name) -> i64 { panic!() }
    fn visit_stmt(&mut self, s: &Stmt) -> i64 {
        match *s {
            Stmt::Expr(ref e) => self.visit_expr(e),
            Stmt::Let(..) => unimplemented!(),
        }
    }

    fn visit_expr(&mut self, e: &Expr) -> i64 {
        match *e {
            Expr::IntLit(n) => n,
            Expr::Add(ref lhs, ref rhs) => self.visit_expr(lhs) + self.visit_expr(rhs),
            Expr::Sub(ref lhs, ref rhs) => self.visit_expr(lhs) - self.visit_expr(rhs),
        }
    }
}

人们可以实现更多的访问器,例如类型检查器,而不需要修改AST数据。

访问器模式在任何你想将算法应用于异质数据的地方都很有用。 如果数据是同质的,你可以使用一个类似迭代器的模式。 使用访问器对象(而不是功能化的方法)允许访问器是有状态的,从而在节点之间交流信息。

2.2、建造型

处理对象创建机制的设计模式,试图以适合情况的方式创建对象。 对象创建的基本形式可能导致设计问题或增加设计的复杂性。 创建型设计模式通过某种方式控制这种对象的创建来解决这个问题。

2.2.1、生成器

通过对生成器助手的调用构造一个对象。


#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq)]
pub struct Foo {
    // Lots of complicated fields.
    bar: String,
}

impl Foo {
    // This method will help users to discover the builder
    pub fn builder() -> FooBuilder {
        FooBuilder::default()
    }
}

#[derive(Default)]
pub struct FooBuilder {
    // Probably lots of optional fields.
    bar: String,
}

impl FooBuilder {
    pub fn new(/* ... */) -> FooBuilder {
        // Set the minimally required fields of Foo.
        FooBuilder {
            bar: String::from("X"),
        }
    }

    pub fn name(mut self, bar: String) -> FooBuilder {
        // Set the name on the builder itself, and return the builder by value.
        self.bar = bar;
        self
    }

    // If we can get away with not consuming the Builder here, that is an
    // advantage. It means we can use the FooBuilder as a template for constructing
    // many Foos.
    pub fn build(self) -> Foo {
        // Create a Foo from the FooBuilder, applying all settings in FooBuilder
        // to Foo.
        Foo { bar: self.bar }
    }
}

#[test]
fn builder_test() {
    let foo = Foo {
        bar: String::from("Y"),
    };
    let foo_from_builder: Foo = FooBuilder::new().name(String::from("Y")).build();
    assert_eq!(foo, foo_from_builder);
}
}

优点:

  • 将构建的方法和其他方法分开
  • 防止构造器的泛滥
  • 可用于单行的初始化,也可以用于更复杂的构造

2.2.2、Fold

在数据集的每一项上运行一个算法,以创建一个新的项,从而创建一个全新的集合。

// The data we will fold, a simple AST.
mod ast {
    pub enum Stmt {
        Expr(Box<Expr>),
        Let(Box<Name>, Box<Expr>),
    }

    pub struct Name {
        value: String,
    }

    pub enum Expr {
        IntLit(i64),
        Add(Box<Expr>, Box<Expr>),
        Sub(Box<Expr>, Box<Expr>),
    }
}

// The abstract folder
mod fold {
    use ast::*;

    pub trait Folder {
        // A leaf node just returns the node itself. In some cases, we can do this
        // to inner nodes too.
        fn fold_name(&mut self, n: Box<Name>) -> Box<Name> { n }
        // Create a new inner node by folding its children.
        fn fold_stmt(&mut self, s: Box<Stmt>) -> Box<Stmt> {
            match *s {
                Stmt::Expr(e) => Box::new(Stmt::Expr(self.fold_expr(e))),
                Stmt::Let(n, e) => Box::new(Stmt::Let(self.fold_name(n), self.fold_expr(e))),
            }
        }
        fn fold_expr(&mut self, e: Box<Expr>) -> Box<Expr> { ... }
    }
}

use fold::*;
use ast::*;

// An example concrete implementation - renames every name to 'foo'.
struct Renamer;
impl Folder for Renamer {
    fn fold_name(&mut self, n: Box<Name>) -> Box<Name> {
        Box::new(Name { value: "foo".to_owned() })
    }
    // Use the default methods for the other nodes.
}

在AST上运行Renamer的结果是一个与旧AST相同的新AST,但每个名字都改为foo。 现实生活中的folder可能会在结构本身的节点之间保留一些状态。

也可以定义一个folder,将一个数据结构映射到一个不同的(但通常是类似的)数据结构。 例如,我们可以将ASTfold成HIR树(HIR代表high-level intermediate representation,高级中间表示法)。

通过对结构中的每个节点进行一些操作来映射一个数据结构是很常见的。 对于简单数据结构的简单操作,可以使用Iterator::map来完成。 对于更复杂的操作,也许前面的节点会影响后面节点的操作,或者在数据结构上的迭代不是简单的,使用fold模式更合适。

与访问器模式一样,fold模式允许我们将数据结构的遍历与对每个节点进行的操作分开。

2.3、结构型

通过确定一种实现实体间关系的简单方法来简化设计的设计模式

2.3.1、组合结构体

有时一个大的结构体会给借用检查器带来问题——虽然字段可以被独立借用,但有时整个结构体最终会被一次性使用,从而妨碍其他用途。 一个解决方案可能是将该结构体分解为几个较小的结构体。 然后将这些结构体组合为原始结构体。 然后每个结构体都可以被单独借用,并具有更灵活的行为。

这往往会在其他方面带来更好的设计:应用这种设计模式往往能发现更小的功能单元。


#![allow(unused)]
fn main() {
	// A is now composed of two structs - B and C.
	struct A {
	    b: B,
	    c: C,
	}
	struct B {
	    f2: u32,
	}
	struct C {
	    f1: u32,
	    f3: u32,
	}
	
	// These functions take a B or C, rather than A.
	fn foo(b: &mut B) -> &u32 { &b.f2 }
	fn bar(c: &mut C) -> u32 { c.f1 + c.f3 }
	
	fn baz(a: &mut A) {
	    let x = foo(&mut a.b);
	    // Now it's OK!
	    let y = bar(&mut a.c);
	    println!("{}", x);
	}
}

这样可以绕过借用检查器的限制,通常会产生一个更好的设计。

有时,较小的结构体并不是很好的抽象,所以我们最终得到了一个更糟糕的设计。 这可能是一种“代码气味”,表明该程序应该以某种方式进行重构。

2.3.2、倾向于较小的Crates

倾向于选择能做好一件事的较小的crates。

Cargo和crates.io使得添加第三方库变得很容易,比C或C++等语言要容易得多。 此外,由于crates.io上的包在发布后不能被编辑或删除,任何现在能工作的构建在未来也应该继续工作。 我们应该利用这种工具的优势,使用更小、更细的依赖关系。

优点:

  • 小的crates更容易理解,并鼓励更多的模块化代码。
  • Crates允许在项目之间重用代码。 例如,urlcrate是作为Servo浏览器引擎的一部分而开发的,但后来在该项目之外被广泛使用。
  • 由于Rust的编译单元是crate,将一个项目分割成多个crate可以使更多的代码被并行构建。

2.3.3、把不安全因素放在小模块中

如果你有unsafe的代码,创建一个尽可能小的模块,它可以坚持在不安全的基础上建立一个最小的安全接口所需的不变量。 将其嵌入到一个更大的模块中,该模块只包含安全代码,并提供一个符合人体工程学的接口。 注意,外部模块可以包含直接调用不安全代码的不安全函数和方法。用户可以用它来获得速度上的好处。

优点:

  • 限制必须被审计的不安全代码。
  • 编写外部模块要容易得多,因为你可以依靠内部模块的保证。

2.4、外部函数接口(FFI)

2.4.1、基于对象的API

当在Rust中设计暴露于其他语言的API时,有一些重要的设计原则与正常的Rust API设计相反:

  1. 所有的封装类型都应该被Rust拥有,由用户管理,并且不透明。
  2. 所有的事务性数据类型都应该由用户拥有,并且是透明的。
  3. 所有的库行为应该是作用于封装类型的函数。
  4. 所有的库行为都应该被封装成类型,且不是基于结构,而是基于出处/生命周期。

目的:
Rust有对其他语言的内置FFI支持。 它为crate作者提供一种方法,通过不同的ABI(尽管这对这种做法并不重要)提供与C兼容的API。

设计良好的Rust FFI遵循了C语言API的设计原则,同时在Rust中尽可能地减少设计的妥协。任何外部API都有三个目标:

1.使其易于在目标语言中使用。
尽可能避免API在Rust侧控制内部不安全性。
尽可能地减少内存不安全性和Rustundefined behaviour的可能性。

Rust代码必须在一定程度上相信外部语言的内存安全性。 然而,Rust侧每一点unsafe的代码都是产生错误的机会,或者加剧了undefined behaviour。

例如,如果一个指针的出处是错误的,这可能是由于无效的内存访问造成的段错误。 同时,如果它被不安全的代码所操纵,它就可能成为全面的堆损坏。

基于对象的API设计允许编写具有良好内存安全特性的垫片代码,拥有明确的安全界限。

POSIX标准定义了访问文件式数据库的API,被称为DBM。 它是一个“基于对象”的API的优秀例子。

struct DBM;
typedef struct { void *dptr, size_t dsize } datum;

int     dbm_clearerr(DBM *);
void    dbm_close(DBM *);
int     dbm_delete(DBM *, datum);
int     dbm_error(DBM *);
datum   dbm_fetch(DBM *, datum);
datum   dbm_firstkey(DBM *);
datum   dbm_nextkey(DBM *);
DBM    *dbm_open(const char *, int, mode_t);
int     dbm_store(DBM *, datum, datum, int);

这个API定义了两种类型:DBM和datum。

DBM类型即上文所称的“封装类型”。 它被设计为包含内部状态,并作为库行为的入口。

它对用户是完全不透明的,用户不能自己创建一个DBM,因为他们不知道它的大小和布局。 相反,他们必须调用dbm_open,而这只能给他们一个指向DBM的指针。

这意味着所有的DBM在Rust意义上是由库“拥有”的。 未知大小的内部状态被保存在由库控制的内存中,而不是用户。 用户只能通过open和close来管理它的生命周期,并通过其他函数对它进行操作。

datum类型即上文所称的“事务性数据类型”。 它被设计用来促进库和用户之间的信息交流。

该数据库被设计用来存储“非结构化数据”,没有预先定义的长度或意义。 因此,datum相当于C语言中的Rust slice:一串字节,以及有多少个字节的计数。主要的区别是没有类型信息,也就是void所表示的。

请记住,这个头文件是从库的角度来写的。 用户可能有一些他们正在使用的类型,这些类型有已知的大小。 但是库并不关心,根据C语言的转换规则,指针后面的任何类型都可以被转换为void。

如前所述,这种类型对用户来说是透明的,同时这个类型也是由用户拥有的。 由于其内部指针,这有微妙的影响。 问题是,谁拥有这个指针所指向的内存?

对于最佳的内存安全性来说,答案是“用户”。 但是在诸如检索一个值的情况下,用户不知道如何正确地分配它(因为他们不知道这个值有多长)。 在这种情况下,库的代码应该使用用户可以访问的堆——比如C库的malloc和free——然后在Rust意义上转移所有权。

这似乎都是猜测,但这就是C语言中指针的含义。 它和Rust的意思是一样的:“用户定义的生命周期”。 库的用户需要阅读文档,以便正确使用它。 也就是说,有一些决定,如果用户做错了,会产生或大或小的后果。 尽量减少这些是这个最佳实践的目的,关键是要转移一切透明事务的所有权。

优点:

    1. 不要用不是由dbm_open返回的指针调用任何函数(无效访问或损坏)。
    1. 关闭之后,不要在指针上调用任何函数(在free后使用)。
    1. 任何datum上的dptr必须是NULL,或者指向一个有效的内存片,其长度为所声明的长度。

此外,它还避免了很多指针出处的问题。 为了理解原因,让我们深入考虑一个替代方案:键的迭代。

Rust的迭代器是众所周知的。 当实现一个迭代器时,程序员会给它的所有者做一个单独的类型,有一定的生命周期,并实现Iteratortrait。

2.4.2、类型合并

这种模式的设计是为了允许优雅地处理多个相关类型,同时最大限度地减少内存不安全的表面积。

Rust的别名规则的基石之一是生命周期。 这确保了类型之间的许多访问模式都是内存安全的,包括数据竞争安全。

然而,当Rust类型被输出到其他语言时,它们通常被转化为指针。 在Rust中,指针意味着“用户管理着被指向者的生命周期”。 避免内存不安全是他们的责任。

因此需要对用户的代码有一定程度的信任,特别是在Rust无能为力的释放后使用方面。 然而,有些API设计对另一种语言编写的代码造成的负担比其他设计更重。

风险最低的API是“综合封装”,即与一个对象的所有可能的交互都被放入一个“封装器类型”中,保持着Rust API的整洁。

struct MySetWrapper {
    myset: MySet,
    iter_next: usize,
}

impl MySetWrapper {
    pub fn first_key(&mut self) -> Option<&Key> {
        self.iter_next = 0;
        self.next_key()
    }
    pub fn next_key(&mut self) -> Option<&Key> {
        if let Some(next) = self.myset.keys().nth(self.iter_next) {
            self.iter_next += 1;
            Some(next)
        } else {
            None
        }
    }
}

三、反面模式

反面模式是一种解决反复出现的问题的方法,通常是无效的,并有可能产生很大的反面作用。与知道如何解决一个问题一样有价值的是知道不能这样解决这个问题。相对于设计模式,反面模式给我们提供了很好的反例来考虑。反面模式并不局限于代码。例如,一个流程也可以是一个反面模式。

3.1、通过 Clone 来满足借用检查器

借用检查器通过确保以下两种情况来防止Rust用户开发不安全的代码:只存在一个可变引用,或者可能存在多个但都是不可变引用。 如果编写的代码不符合这些条件,当开发者通过克隆变量来解决编译器错误时,就会出现这种反面模式。

#![allow(unused)]
fn main() {
	// define any variable
	let mut x = 5;
	
	// Borrow `x` -- but clone it first
	let y = &mut (x.clone());
	
	// perform some action on the borrow to prevent rust from optimizing this
	//out of existence
	*y += 1;
	
	// without the x.clone() two lines prior, this line would fail on compile as
	// x has been borrowed
	// thanks to x.clone(), x was never borrowed, and this line will run.
	println!("{}", x);
}

3.2、#[deny(warnings)]

一个善意的crate作者想确保他们的代码在构建时不会出现警告。所以他用以下内容来注释其crate根。

#![deny(warnings)]

优点:

注释很短,如果出现错误,会停止构建。

缺点:

通过不允许编译器产生构建警告,crate作者失去了Rust引以为傲的稳定性。 有时,新特性或旧的错误特性需要改变处理逻辑,因此,在转为deny之前,会有warn的lint,并有一定的缓冲期。

例如,人们发现一个类型可以有两个具有相同方法的impl块。 这被认为是一个坏主意,但为了使过渡顺利,overlapping-inherent-impls lint被引入,给那些偶然发现这个事实的人一个警告,即使它在未来的版本中将成为一个硬编码错误。

另外,有时API会被废弃,所以在它们消失前使用会发出警告。

当某些事情发生改变,所有这些都有潜在的破坏构建的可能性。

此外,提供额外lint的crate(例如rust-clippy)不能再被使用,除非注释被删除。这可以通过[-cap-lints]来缓解。 命令行参数–cap-lints=warn可将所有denylint错误变成警告。

替代方案:
1、我们可以将构建设置与代码解耦
2、我们可以指明我们想要显式拒绝的lint

RUSTFLAGS="-D warnings" cargo build
#[deny(bad-style,
       const-err,
       dead-code,
       improper-ctypes,
       non-shorthand-field-patterns,
       no-mangle-generic-items,
       overflowing-literals,
       path-statements ,
       patterns-in-fns-without-body,
       private-in-public,
       unconditional-recursion,
       unused,
       unused-allocation,
       unused-comparisons,
       unused-parens,
       while-true)]
#[deny(missing-debug-implementations,
       missing-docs,
       trivial-casts,
       trivial-numeric-casts,
       unused-extern-crates,
       unused-import-braces,
       unused-qualifications,
       unused-results)]

有些人可能还想在他们的列表中加入missing-copy-implementationslint。

请注意,我们没有明确添加deprecated的lint,因为可以肯定的是,未来会有更多被废弃的API。

3.3、解引用多态性

滥用Deref trait来模拟结构体间的继承,从而重用方法。

use std::ops::Deref;

struct Foo {}

impl Foo {
    fn m(&self) {
        //..
    }
}

struct Bar {
    f: Foo,
}

impl Deref for Bar {
    type Target = Foo;
    fn deref(&self) -> &Foo {
        &self.f
    }
}

fn main() {
    let b = Bar { f: Foo {} };
    b.m();
}

Rust中没有结构体的继承。相反,我们使用组合,并在Bar中包含一个Foo的实例(因为字段是一个值,它被内联存储,所以如果有字段,它们在内存中的布局与Java版本相同(可能,如果你想确定,你应该使用#[repr©]))。

为了使方法调用生效,我们为Bar实现了Deref,以Foo为目标(返回嵌入的Foo字段)。这意味着当我们解除对Bar的引用时(例如,使用*),我们将得到一个Foo。 这很奇怪。解引用通常从对T的引用中得到一个T,这里我们有两个不相关的类型。 然而,由于点运算符做了隐式解引用,这意味着方法调用将搜索Foo和Bar的方法。

你可能感兴趣的:(跟小嘉学,Rust,编程,rust,设计模式,开发语言)