Создание функции Rust, возвращающей &str или String

Перевод | Автор оригинала: Herman J. Radtke III

Мы узнали, как создать функцию, которая принимает в качестве аргумента String или &str. Теперь я хочу показать вам, как создать функцию, возвращающую либо String, либо &str. Я также хочу обсудить, почему мы хотим это сделать. Для начала давайте напишем функцию для удаления всех пробелов из данной строки. Наша функция может выглядеть примерно так:

fn remove_spaces(input: &str) -> String {
   let mut buf = String::with_capacity(input.len());

   for c in input.chars() {
      if c != ' ' {
         buf.push(c);
      }
   }

   buf
}

Эта функция выделяет память для строкового буфера, перебирает каждый символ ввода и добавляет все непробельные символы в строковый буфер. Теперь я спрашиваю: а что, если мой ввод вообще не содержит пробелов? Ввод значения будет таким же, как у buf. В этом случае было бы эффективнее вообще не создавать buf. Вместо этого мы хотели бы просто вернуть заданный ввод обратно вызывающей стороне. Тип ввода - &str, но наша функция возвращает String. Мы могли бы изменить тип ввода на String:

fn remove_spaces(input: String) -> String { ... }

но это вызывает две проблемы. Во-первых, вводя тип String, мы заставляем вызывающего объекта передать право владения вводом в нашу функцию. Это предотвращает использование вызывающей стороной этого значения в будущем. Мы должны брать на себя ответственность за ввод только в том случае, если он нам действительно нужен. Во-вторых, входные данные могут уже иметь тип &str, и теперь мы заставляем вызывающую сторону преобразовывать его в String, что препятствует нашим попыткам не выделять новую память при создании buf.

Клонирование при записи

Что нам действительно нужно, так это возможность возвращать нашу входную строку (&str), если нет пробелов, и возвращать новую строку (String), если есть пробелы, которые нам нужно удалить. Здесь можно использовать тип «клонирование при записи» или «Корова». Тип «Корова» позволяет нам абстрагироваться от того, принадлежит что-то или заимствовано. В нашем примере &str - это ссылка на существующую строку, так что это будут заимствованные данные. Если есть пробелы, нам нужно выделить память для новой строки. Эта новая строка принадлежит переменной buf. Обычно мы передаем право собственности на buf, возвращая его вызывающей стороне. При использовании Cow мы хотим передать владение buf типу Cow и вернуть его.

use std::borrow::Cow;

fn remove_spaces<'a>(input: &'a str) -> Cow<'a, str> {
    if input.contains(' ') {
        let mut buf = String::with_capacity(input.len());

        for c in input.chars() {
            if c != ' ' {
                buf.push(c);
            }
        }

        return Cow::Owned(buf);
    }

    return Cow::Borrowed(input);
}

Наша функция теперь проверяет, содержит ли данный ввод пробел, и только затем выделяет память для нового буфера. Если ввод не содержит пробела, ввод просто возвращается. Мы немного усложняем время выполнения, чтобы оптимизировать распределение памяти. Обратите внимание, что у нашего типа Cow такое же время жизни, как у типа &str. Как мы обсуждали ранее, компилятор должен отслеживать ссылку &str, чтобы знать, когда он может безопасно освободить (или удалить) память.

Прелесть Cow в том, что он реализует трейт Deref, поэтому вы можете вызывать неизменяемые функции, не зная, является ли результат новым строковым буфером или нет. Пример:

let s = remove_spaces("Herman Radtke");
println!("Length of string is {}", s.len());

Если мне нужно изменить s, я могу преобразовать его в принадлежащую переменную с помощью функции into_owned(). Если вариант Cow уже был в собственности, мы просто перемещаем право собственности. Если вариант Cow заимствован, то мы выделяем память. Это позволяет нам лениво клонировать (выделять память) только тогда, когда мы хотим записать (или изменить) переменную.

Пример мутации Cow::Borrowed:

let s = remove_spaces("Herman"); // s is a Cow::Borrowed variant
let len = s.len(); // immutable function call using Deref
let owned: String = s.into_owned(); // memory is allocated for a new string

Пример мутации Cow::Owned:

let s = remove_spaces("Herman Radtke"); // s is a Cow::Owned variant
let len = s.len(); // immutable function call using Deref
let owned: String = s.into_owned(); // no new memory allocated as we already had a String

Идея Cow двоякая:

  1. Отложите выделение памяти как можно дольше. В лучшем случае нам никогда не придется выделять новую память.
  2. Позвольте вызывающей стороне нашей функции remove_spaces не заботиться о том, была выделена память или нет. В любом случае использование типа «Корова» одинаково.

Использование свойства Into Trait

Ранее мы обсуждали использование трейта Into для преобразования &str в String. Мы также можем использовать типаж Into, чтобы преобразовать &str или String в правильный вариант Cow. Вызов .into() компилятор выполнит преобразование автоматически. Использование .into() не ускорит или не замедлит код. Это просто вариант, позволяющий избежать явного указания Cow::Owned или Cow::Borrowed.

fn remove_spaces<'a>(input: &'a str) -> Cow<'a, str> {
    if input.contains(' ') {
        let mut buf = String::with_capacity(input.len());
        let v: Vec<char> = input.chars().collect();

        for c in v {
            if c != ' ' {
                buf.push(c);
            }
        }

        return buf.into();
    }
    return input.into();
}

Мы также можем немного очистить это, используя только итераторы:

fn remove_spaces<'a>(input: &'a str) -> Cow<'a, str> {
    if input.contains(' ') {
        input
        .chars()
        .filter(|&x| x != ' ')
        .collect::<std::string::String>()
        .into()
    } else {
        input.into()
    }
}

Использование коровы в реальном мире

Мой пример удаления пробелов может показаться немного надуманным, но у этой стратегии есть несколько отличных практических применений. Внутри ядра Rust есть функция, которая конвертирует байты в UTF-8 с потерями, и функция, которая переводит CRLF в LF. Обе эти функции имеют случай, когда &str может быть возвращен в оптимальном случае, и другой случай, когда необходимо выделить String. Другие примеры, которые я могу придумать, - это правильное кодирование строки xml / html или правильное экранирование SQL-запроса. Во многих случаях ввод уже правильно закодирован или экранирован. В таких случаях лучше просто вернуть строку ввода обратно вызывающей стороне. Когда необходимо изменить ввод, мы вынуждены выделить новую память в виде строкового буфера и вернуть ее вызывающей стороне. Зачем использовать String::with_capacity()?

Пока мы обсуждаем тему эффективного управления памятью, обратите внимание, что я использовал String::with_capacity() вместо String::new() при создании строкового буфера. Вы можете использовать String::new() вместо String::with_capacity(), но более эффективно выделять память для буфера сразу, а не повторно выделять память, когда мы помещаем в буфер больше символов. Давайте рассмотрим, что делает Rust, когда мы используем String::new(), а затем помещаем символы в строку.

Строка на самом деле представляет собой Vec кодовых точек UTF-8. Когда вызывается String::new(), Rust создает вектор с нулевой емкостью в байтах. Если мы затем поместим символ a в строковый буфер, например input.push ('a'), Rust должен увеличить емкость вектора. В этом случае будет выделено 2 байта памяти. По мере того, как мы помещаем больше символов и превышаем емкость, Rust удваивает размер строки за счет перераспределения памяти. Он будет продолжать увеличиваться вдвое каждый раз при превышении емкости. Последовательность распределения памяти: 0, 2, 4, 8, 16, 32 ... 2 ^ n, где n - количество раз, когда Rust обнаруживал превышение емкости. Перераспределение памяти происходит очень медленно (edit: kmc_v3 объяснил, что это может быть не так медленно, как я думал). Rust не только должен запрашивать у ядра новую память, он также должен копировать содержимое вектора из старой области памяти в новую область памяти. Ознакомьтесь с исходным кодом Vec::push, чтобы увидеть логику изменения размера из первых рук.

В общем, мы хотим выделять новую память только тогда, когда она нам нужна, и выделять столько, сколько нам нужно. Для небольших строк, таких как remove_spaces («Герман Радтке»), случайное перераспределение памяти не имеет большого значения. Что, если бы я хотел удалить все пробелы в каждом файле JavaScript для своего веб-сайта? Накладные расходы на перераспределение памяти для буфера намного выше. При передаче данных в вектор (String или другой) может быть хорошей идеей указать емкость для начала. Лучшая ситуация, когда вы уже знаете длину и емкость можно точно установить. Комментарии к коду Vec содержат аналогичное предупреждение.

Связанный