Cow 的妙用

cow 的中文翻译是奶牛,显然 rust 中的 Cow 表示的不是这个意思。

CowCopy on write 的缩写,是一种智能指针,适合读多写少的场景。

直接看代码:


#![allow(unused)]
fn main() {
let name = String::from("lisiur");
}

name 的内存布局如下:

此时如果我们想复用 name 所指向的那块内存数据,有这么两种方式:

方式一:引用


#![allow(unused)]
fn main() {
let name = String::from("lisiur");
let new_name = &name;
}

内存布局如下:

方式二:克隆


#![allow(unused)]
fn main() {
let name = String::from("lisiur");
let new_name = name.clone();
}

内存布局如下:

两种方式各有优缺点,引用是很廉价的操作,不会涉及堆内存的分配和拷贝,但是需要被借用检查限制。

克隆是很昂贵的操作,需要堆内存的分配和拷贝,但是拥有新数据的所有权,后续操作会更灵活。

一般情况下,如果获取 name 只是为了读操作的话,用引用的方式比较合适;但如果需要写操作,则必须使用克隆的方式了。

那么问题来了,如果我既有可能只有读操作,也有可能涉及写操作该怎么处理呢?这时为了顾及写操作,我们好像只能退而求其次的使用克隆的方式。 但这在读多写少的情况下存在大量不必要的内存分配。举个例子:

假设我们要对一个字符串按照空格切分,拿到切分后的第一个字符串片段,如果该字符串片段首字母小写,则将其改成大写字母并返回。


#![allow(unused)]
fn main() {
fn extract_return_string(s: &str) -> String {
    match s.find(" ") {
        Some(i) => {
            uppercase_first(&s[0..i])
        }
        None => {
            uppercase_first(s)
        }
    }
}

fn uppercase_first(s: &str) -> String {
    let mut chars = s.chars();
    chars.next().unwrap().to_uppercase().collect::<String>() + chars.as_str()
}
}

对于这种需求,函数的返回值貌似只能是 String。但是有这么一种情况,那就是输入的 s 本身就是符合输出结果的,此时如果返回 String
势必会对 s 进行不必要的内存拷贝。如果这种情况占大多数,并且每个 s 的长度又足够大时,这种浪费就会更加明显。

此时就是 Cow 大展身手的时候了:


#![allow(unused)]
fn main() {
fn extract_return_cow(s: &str) -> Cow<str> {
    match s.find(" ") {
        Some(i) => {
            Cow::Owned((uppercase_first(&s[0..i])))
        }
        None => {
            if s.starts_with(|c: char| c.is_uppercase()) {
                Cow::Borrowed(s)
            } else {
                Cow::Owned(uppercase_first(s))
            }
        }
    }
}

fn uppercase_first(s: &str) -> String {
    let mut chars = s.chars();
    chars.next().unwrap().to_uppercase().collect::<String>() + chars.as_str()
}
}

我们可以看到,对于包含空格的 s 或者没有首字符大写的 s 来说会进行必要的克隆操作(Cow::Owned),而对于首字母已经大写,并且不包含空格的 s 来说,则直接借用其值(Cow::Borrowed),避免了无谓的消耗。

我们可以简单做个测试对比下:

fn test(s: &str) {
    const N: usize = 10_000_000;

    let start = SystemTime::now();
    for _i in 0..N {
        extract_return_string(s);
    }
    println!("{}", start.elapsed().unwrap().as_secs_f32());

    let start = SystemTime::now();
    for _i in 0..N {
        extract_return_cow(s);
    }
    println!("{}", start.elapsed().unwrap().as_secs_f32());
}

fn main() {
    test("lisiur day");
    test("Lisiur");
}

在我的机器上,对于 "lisiur day" 来说两种方法相差无几,几乎都是 0.9s 左右,而对于 "Lisiur" 来说,extract_return_string 稳定在 0.8s 左右,而 extract_return_cow 稳定在 0.3s 左右,优化效果还是很明显的。

大家可能会说,如果返回的结果后续需要进行修改怎么办呢?对于 extract_return_string 来说这很容易,因为其已经将 String 的所有权返回了,对于 extract_return_cow 来说,其有可能还只是持有 String 的引用而已。此时可以调用 to_mut 方法来保证 cow 已经持有数据的所有权(可能会发生内存拷贝)。


#![allow(unused)]
fn main() {
// 对于返回 String 来说,可以直接修改
let mut s = extract_return_string("Lisiur");
s.push_str(' Day');
println!("{}", s); // Lisiur Day

// 对于返回 Cow 来说,可以先确保已经获取所有权
let mut s = extract_return_cow("Lisiur");
s.to_mut().push_str(' Day');
println!("{}", s); // Lisiur Day
}

我们知道对于 "Lisiur" 来说,extract_return_cow 返回的是 Cow::Borrowed,其内部存储的是一个借用值,如果我们想对该值进行加工,就需要调用 to_mutCow::Borrowed 变成 Cow::Owned,也即此时才会发生内存拷贝。

对 Cow::Owned 调用 to_mut,不会产生副作用。

由此可见,如果后续一定会对返回值进行写操作的话,Cow 其实并没有任何优势,只是延迟了内存拷贝的时间而已。所以就如同开篇所说的那样,Cow 只适用于读多写少的场景。