Инженер-программист
0,0
рейтинг
3 января в 02:34

Разработка → Создание функции на Rust, которая принимает String или &str перевод

От переводчика


КДПВСтатья — одна из серии постов, рассказывающих об использовании некоторых полезных библиотечных типажей и связанных с ними идиом Rust на примере строковых типов данных. Информация бесспорно полезная как для начинающих программистов на Rust, так и для тех, кто уже успел себя немного попробовать в этом языке, но ещё не совсем освоился с богатой библиотекой типажей. Оригинальный пост содержит несколько неточностей и опечаток в коде, которые я постарался исправить в процессе перевода, однако в общем и целом описанные подходы и мотивация правильные, подходящие под понятие «лучших практик», а потому заслуживают внимания.


В моём последнем посте (англ.) мы много говорили об использовании &str как предпочтительного типа для функций, принимающих строковые аргументы. Ближе к концу поста мы обсудили, когда лучше использовать String, а когда &str в структурах (struct). Хотя я думаю, что в целом совет хорош, но в некоторых случаях использование &str вместо String не оптимально. Для таких случаев нам понадобится другая стратегия.

Структура со строковыми полями типа String


Посмотрите на структуру Person, представленную ниже. Для целей нашего обсуждения, положим, что в поле name есть реальная необходимость. Мы решим использовать String вместо &str.

struct Person {
    name: String,
}

Теперь нам нужно реализовать метод new(). Следуя совету из предыдущего поста, мы предпочтём тип &str:

impl Person {
    fn new(name: &str) -> Person {
        Person { name: name.to_string() }
    }
}

Пример заработает, только если мы не забудем о вызове .to_string() в методе new() (На самом деле здесь лучше использовать метод to_owned(), поскольку метод to_string() для размещения строки в памяти использует довольно тяжёлую библиотеку форматирования текста, а to_owned() просто копирует строковый срез &str напрямую в новый объект String — прим. перев.). Однако, удобство использования функции оставляет желать лучшего. Если использовать строковый литерал, то мы можем создать новую запись Person так: Person::new("Herman"). Но если у нас уже есть владеющая строка String, то нам нужно получить ссылку на неё:

let name = "Herman".to_string();
let person = Person::new(name.as_ref());

Похоже, как будто бы мы ходим кругами. Сначала у нас есть String, затем мы вызываем as_ref() чтобы превратить её в &str, только затем, чтобы потом превратить её обратно в String внутри метода new(). Мы могли бы вернуться к использованию String, вроде fn new(name: String) -> Person, но тогда нам пришлось бы заставлять пользователя постоянно вызывать .to_string(), если тот захочет создать Person из строкового литерала.

Конверсии с помощью Into


Мы можем сделать нашу функцию проще в использовании с помощью типажа Into. Этот типаж будет автоматически конвертировать &str в String. Если у нас уже есть String, то конверсии не будет.

struct Person {
    name: String
}

impl Person {
    fn new<S: Into<String>>(name: S) -> Person {
        Person { name: name.into() }
    }
}

fn main() {
    let person = Person::new("Herman");
    let person = Person::new("Herman".to_string());
}

Синтаксис сигнатуры new() теперь немного другой. Мы используем обобщённые типы (англ.) и типажи (англ.), чтобы объяснить Rust, что некоторый тип S должен реализовать типаж Into для типа String. Тип String реализует Into как пустую операцию, потому что String уже имеется на руках. Тип &str реализует Into с использованием того же .to_string() (на самом деле нет — прим. перев.), который мы использовали с самого начала в методе new(). Так что мы не избегаем необходимости вызывать .to_string(), а убираем необходимость делать это пользователю метода. У вас может возникнуть вопрос, не вредит ли использование Into производительности, и ответ — нет. Rust использует статическую диспетчеризацию (англ.) и мономорфизацию для обработки всех деталей во время компиляции.

Такие слова, как статическая диспетчеризация или мономорфизация могут немного сбить вас с толку, но не волнуйтесь. Всё, что вам нужно знать, так это то, что показанный выше синтаксис позволяет функциям принимать и String, и &str. Если вы думаете, что fn new<S: Into>(name: S) -> Person — очень длинный синтаксис, то да, вы правы. Однако, важно заметить, что в выражении Into нет ничего особенного. Это просто названия типажа, который является частью стандартной библиотеки Rust. Вы сами могли бы его написать, если бы захотели. Вы можете реализовать похожие типажи, если посчитаете их достаточно полезными, и опубликовать на crates.io. Вся эта мощь, сосредоточенная в пользовательском коде, и делает Rust таким восхитительным языком.

Другой способ написать Person::new()


Можно использовать синтаксис where, который, возможно, будет проще читать, особенно если сигнатура функции становится более сложной:

struct Person {
    name: String,
}

impl Person {
    fn new<S>(name: S) -> Person where S: Into<String> {
        Person { name: name.into() }
    }
}

Что ещё почитать


Перевод: Herman J. Radtke III
Константин @kstep
карма
47,0
рейтинг 0,0
Инженер-программист
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (35)

  • +1
    На самом деле здесь лучше использовать метод to_owned(), поскольку метод to_string() для размещения строки в памяти использует довольно тяжёлую библиотеку форматирования текста, а to_owned() просто копирует строковый срез &str напрямую в новый объект String — прим. перев.
    То чувство, когда переводчик разбирается в предмете лучше автора.

    Зачем вообще в расте разделение на String и &str? Почему не используется один тип, как в остальных языках?
    • +4
      Потому, что, String владеет буфером, а значит &String указывает на обьект владеющий буфером. Но статическим буфером (строковым литералом) никто владеть не может, так что для него придумали тип str, который сам по себе непонятно что собой представляет, но вот &str — это указатель на строковый литерал, что уже полезно.
      Его можно было бы запихнуть и в &String, но тогда нужно делать каждый раз аллокацию/деаллокацию. Итого, N аллокаций по одной на каждый вызов кода со строковым литералом против нуля с &str. Профит!

      Насчет лучше разбирается: я думаю, это культурное различие и автор хотел сделать код и обьяснение понятнее новичкам.
    • 0
      Может, через какое-то время эта оптимизация все-таки станет менее актуальной — https://github.com/rust-lang/rust/pull/30652
      • 0
        Возможно, когда-нибудь. Но пока так.
      • 0
        Как специализация здесь поможет?
        • 0
          Сейчас метод to_string() определёт в трейте ToString, у которого есть blanket-реализация для всех Display-типов:
          impl<T: Display> ToString for T {
              fn to_string(&self) -> String {
                  format!("{}", self)
              }
          }
          


          Соответственно, для String/&str также используется format!(), что неэффективно.

          Как только специализация будет доступна, можно будет переопределить ToString для str через String::from_str(). Без специализации сделать такую реализацию нельзя, потому что она будет конфликтовать с blanket-реализацией для T: Display.
    • 0
      Вообще говоря, вы не правы. Rust многие (в том числе я), видят правопреемника C++. Так вот, в C++ есть некоторый аналог среза, когда в функцию надо передать часть строки без копирования. Правда делается это, обычно, двумя параметрами — либо указателем на начало строки и длиной строки, либо двумя итераторами. Первый способ считается более «сишным» и несколько менее красивым. В Rust просто создали отдельную сущность для этого, называемую срезом, у которой внутри всё те же два параметра. Плюс, всё это отлично легло на концепцию владения (которая и так давно есть в языках типа С и С++, просто не поддерживается на уровне языка).
  • 0
    а почему в new нельзя просто принимать Into без генерик-параметра?
    • 0
      простите, парсер съел. я имел ввиду:
      а почему в new нельзя просто принимать тип без генерик-параметра? аля
      fn new(name: Into<String>)
      
      • 0
        Потому, что это не тип.
        • 0
          Да, это типаж. Т.е. типаж нельзя указать в качестве типа параметра функции?
          • 0
            У переменной должен быть тип. Into<String\> — это не тип, это трейт. Следственно, запись
            name: Into<String>
            
            не имеет смысла.
            • 0
              Т.е. у функции всегда точный тип параметра, только в случае с дженериком, для каждого использования компилятором будет создана своя функция с конкретным типом?
              • 0
                Да, это называется мономорфизация. Есть и вариант с динамическим полиморфизмом, про него ниже написали.
          • 0
            Для полноты картины, некоторые типажи можно использовать в качестве типов (так называемые «типажи-объекты», а типажи, которые можно использовать как объекты называются «объектно безопасными» (object safe)), но только по указателям (вроде &Display), т.к. их размер не известен во время компиляции. Но типаж Into<T> к таким не относится, т.к. принимает тип-параметр.
            • 0
              В этом случае типаж — это тоже не тип, а для каждого object safe-типажа неявно создается тип &TraitName, для которого авоматически создается реализация трейта TraitName, и этот тип — что-то вроде класса в ООП-языках, в обьектах этого типа будет таблица виртуальных функций, вместо мономорфизации.
              «Указатель на трейт» — тоже бессмысленная фраза, так как трейт не является типом.
              • 0
                Спасибо, это сильно улучшило моё понимание трейтов!
              • 0
                Получается этакий алгебраический тип объединяющий все реализации типажа?
                • 0
                  Типа того, да! Как бы енум, но без паттернматчинга.
                  Но только логически. Фактически, я думаю, это указатель на кучу, под которым vptr и байты исходной структуры. Насколько я понимаю, обычно енумы делают через tagged union.
                • 0
                  У алгебраического типа данных заранее известны все варианты — здесь же что-то более похожее на интерфейс или абстрактный класс в ООП языках.
              • 0
                TraitName — это тип с динамическим размером, аналогично str. На такой тип можно зоздавать разные указатели: — &TraitName, Box, Rc.
                • 0
                  поправка — &TraitName, Box<TraitName>, Rc<TraitName>.
                  • 0
                    А так же Arc<TraitName>, и вообще любой тип, реализующий Deref<Target=TraitName>.
                    • 0
                      Не любой. Насколько я помню, значение дженерикового типа должно быть последним элементом в структуре, чтобы быть unsized.
                      • 0
                        Здесь это не важно абсолютно, Deref<Target=T> преобразует заданный объект в &T, его метод `deref(&self) -> &Self::Target` принимает self по ссылке, поэтому unsized или sized тут Self и Self::Target не играет никакой роли.
                      • 0
                        Вот пользовательский тип может быть unsized только если unsized-поле единственное unsized поле и последнее в списке полей. Но только к трейту Deref и deref coercion это не имеет никакого отношения.
            • 0
              Спасибо, это многое объясняет.
            • 0
              Но типаж Into<T> к таким не относится, т.к. принимает тип-параметр.
              Нет, тип-параметр тут не при чём, тут какое-то искусственное ограничение — у него зачем-то стоит ограничение Sized, а trait-object — это тип c динамическим размером, он не может быть Sized. Зачем это ограничение не знаю, возможно есть какой-то смысл.
              • 0
                Into::into() принимает self по значению, такие трейты не могут быть object-safe, следовательно, из них нельзя сделать трейт-объект.
              • 0
                Всё так, но не так.

                1) Метод Into::into принимает self по значению, это верно. По значению можно передавать только Sized типы, потому что для передачи по значению размер типа должен быть известен при компиляции. Так что stepik777 тут не прав, это не искусственное ограничение, для него есть объективная причина.
                2) Googolplex не совсем прав по поводу того, что типажи, у которых есть методы, принимающие self по значению — не object-safe. Если на сам типаж нет ограничения `trait TraitName: Sized`, то он может оказаться object-safe даже с методами, которые принимают self по значению, просто компилятор потребует, чтобы такие методы содержали в сигнатуре ограничение where Self: Sized. См. например типаж Iterator, который object-safe, но у него есть методы, принимающие self по значению doc.rust-lang.org/src/core/iter.rs.html#376.
                3) Я сам ошибся, когда говорил, что генерик параметр в трейте Into мешает ему быть object-safe, не мешает. Просто компилятор попросит конкретизировать тип при приведении к трейт-объекту, чтобы T в Into был конкретным типом. То есть нету типаж-объекта &Into<T> где T какой-то параметр, но может быть типаж-объект &Into<u32>.

                По итогу stepik777 по большому счёту прав, что Into<T> не может быть типаж-объектом только из-за ограничения на Sized для всего типажа, если бы только ограничения на весь типаж не было, а только метод into имел сигнатуру fn into(self) -> T where Self: Sized, то сделать такой типаж-объект, конкретизировав параметр T, было бы можно. НО это не имело бы никакого смысла, так как вызвать на таком типаж-объекте единственный метод into было бы невозможно в принципе, так как он принимает self по значению, так что использование такого типаж-объекта было бы абсолютно бессмысленно.
  • +1
    Вообще, дженерики в виде трейтов без типов высшего порядка — отстой.
    Например, нельзя для всех коллекций обьектов типа T where T: Display реализовать Display:
    impl<F, T> Display for F<T> where F: Collection<T>, T: Display {...} // oops, compile error
    


    Пока их нет, не могу получить наслаждения от раста, если кодить что-то обобщенное.
    Пока кодишь без дженериков — все круто, как только хочется чуть серьезнее, чем очень простые случаи с ними — все, грусть навевает.
  • +1
    Аналогично, тоже их очень жду. Пока просто обхожусь тем, что есть (ассоциированные типы).
    • 0
      Проблема еще в том, что если их сделают, придется здорово перелопатить стандартную либу, если захочется красиво и идиоматично.
      А они ее уже преждевременно стабилизировали напрочь.
      Сложная ситуация.
  • 0
    let name = "Herman".to_string();
    let person = Person::new(name.as_ref());
    
    Можно вместо name.as_ref() писать просто &name:
    let name = "Herman".to_string();
    let person = Person::new(&name);
    
    Это работает благодаря deref coercions.
    • +1
      Да, конечно. Но тут не хотелось бы заострять внимание на deref coercion, так как речь не про неё, а про повторяющиеся преобразования.
      Тут специально делается акцент на происходящих переходах значение-ссылка-значение, поэтому выбран самый явный метод.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.