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


КДПВСтатья — одна из серии постов, рассказывающих об использовании некоторых полезных библиотечных типажей и связанных с ними идиом 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> как пустую операцию, потому что String уже имеется на руках. Тип &str реализует Into<String> с использованием того же .to_string() (на самом деле нет — прим. перев.), который мы использовали с самого начала в методе new(). Так что мы не избегаем необходимости вызывать .to_string(), а убираем необходимость делать это пользователю метода. У вас может возникнуть вопрос, не вредит ли использование Into<String> производительности, и ответ — нет. Rust использует статическую диспетчеризацию (англ.) и мономорфизацию для обработки всех деталей во время компиляции.

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

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


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


  1. vintage
    03.01.2016 09:12
    +1

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

    Зачем вообще в расте разделение на String и &str? Почему не используется один тип, как в остальных языках?


    1. Monnoroch
      03.01.2016 09:31
      +4

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

      Насчет лучше разбирается: я думаю, это культурное различие и автор хотел сделать код и обьяснение понятнее новичкам.


    1. ozkriff
      03.01.2016 10:29

      Может, через какое-то время эта оптимизация все-таки станет менее актуальной — https://github.com/rust-lang/rust/pull/30652


      1. kstep
        03.01.2016 11:18

        Возможно, когда-нибудь. Но пока так.


      1. stepik777
        03.01.2016 18:18

        Как специализация здесь поможет?


        1. Googolplex
          03.01.2016 21:21

          Сейчас метод 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.


    1. stack_trace
      03.01.2016 13:26

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


  1. fogone
    03.01.2016 11:40

    а почему в new нельзя просто принимать Into без генерик-параметра?


    1. fogone
      03.01.2016 11:45

      простите, парсер съел. я имел ввиду:
      а почему в new нельзя просто принимать тип без генерик-параметра? аля

      fn new(name: Into<String>)
      


      1. Monnoroch
        03.01.2016 11:49

        Потому, что это не тип.


        1. fogone
          03.01.2016 11:55

          Да, это типаж. Т.е. типаж нельзя указать в качестве типа параметра функции?


          1. Monnoroch
            03.01.2016 12:00

            У переменной должен быть тип. Into<String\> — это не тип, это трейт. Следственно, запись

            name: Into<String>
            
            не имеет смысла.


            1. fogone
              03.01.2016 12:10

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


              1. Monnoroch
                03.01.2016 12:11

                Да, это называется мономорфизация. Есть и вариант с динамическим полиморфизмом, про него ниже написали.


          1. kstep
            03.01.2016 12:05

            Для полноты картины, некоторые типажи можно использовать в качестве типов (так называемые «типажи-объекты», а типажи, которые можно использовать как объекты называются «объектно безопасными» (object safe)), но только по указателям (вроде &Display), т.к. их размер не известен во время компиляции. Но типаж Into<T> к таким не относится, т.к. принимает тип-параметр.


            1. Monnoroch
              03.01.2016 12:10

              В этом случае типаж — это тоже не тип, а для каждого object safe-типажа неявно создается тип &TraitName, для которого авоматически создается реализация трейта TraitName, и этот тип — что-то вроде класса в ООП-языках, в обьектах этого типа будет таблица виртуальных функций, вместо мономорфизации.
              «Указатель на трейт» — тоже бессмысленная фраза, так как трейт не является типом.


              1. fogone
                03.01.2016 12:16

                Спасибо, это сильно улучшило моё понимание трейтов!


              1. vintage
                03.01.2016 13:35

                Получается этакий алгебраический тип объединяющий все реализации типажа?


                1. Monnoroch
                  03.01.2016 16:06

                  Типа того, да! Как бы енум, но без паттернматчинга.
                  Но только логически. Фактически, я думаю, это указатель на кучу, под которым vptr и байты исходной структуры. Насколько я понимаю, обычно енумы делают через tagged union.


                1. stepik777
                  03.01.2016 18:24

                  У алгебраического типа данных заранее известны все варианты — здесь же что-то более похожее на интерфейс или абстрактный класс в ООП языках.


              1. stepik777
                03.01.2016 18:07

                TraitName — это тип с динамическим размером, аналогично str. На такой тип можно зоздавать разные указатели: — &TraitName, Box, Rc.


                1. stepik777
                  03.01.2016 18:20

                  поправка — &TraitName, Box<TraitName>, Rc<TraitName>.


                  1. kstep
                    03.01.2016 20:15

                    А так же Arc<TraitName>, и вообще любой тип, реализующий Deref<Target=TraitName>.


                    1. Googolplex
                      03.01.2016 21:23

                      Не любой. Насколько я помню, значение дженерикового типа должно быть последним элементом в структуре, чтобы быть unsized.


                      1. kstep
                        04.01.2016 12:25

                        Здесь это не важно абсолютно, Deref<Target=T> преобразует заданный объект в &T, его метод `deref(&self) -> &Self::Target` принимает self по ссылке, поэтому unsized или sized тут Self и Self::Target не играет никакой роли.


                      1. kstep
                        04.01.2016 12:28

                        Вот пользовательский тип может быть unsized только если unsized-поле единственное unsized поле и последнее в списке полей. Но только к трейту Deref и deref coercion это не имеет никакого отношения.


            1. fogone
              03.01.2016 12:11

              Спасибо, это многое объясняет.


            1. stepik777
              03.01.2016 18:15

              Но типаж Into<T> к таким не относится, т.к. принимает тип-параметр.
              Нет, тип-параметр тут не при чём, тут какое-то искусственное ограничение — у него зачем-то стоит ограничение Sized, а trait-object — это тип c динамическим размером, он не может быть Sized. Зачем это ограничение не знаю, возможно есть какой-то смысл.


              1. Googolplex
                03.01.2016 21:24

                Into::into() принимает self по значению, такие трейты не могут быть object-safe, следовательно, из них нельзя сделать трейт-объект.


              1. kstep
                04.01.2016 12:46

                Всё так, но не так.

                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. Monnoroch
    03.01.2016 16:13
    +1

    Вообще, дженерики в виде трейтов без типов высшего порядка — отстой.
    Например, нельзя для всех коллекций обьектов типа T where T: Display реализовать Display:

    impl<F, T> Display for F<T> where F: Collection<T>, T: Display {...} // oops, compile error
    


    Пока их нет, не могу получить наслаждения от раста, если кодить что-то обобщенное.
    Пока кодишь без дженериков — все круто, как только хочется чуть серьезнее, чем очень простые случаи с ними — все, грусть навевает.


  1. kstep
    03.01.2016 17:33
    +1

    Аналогично, тоже их очень жду. Пока просто обхожусь тем, что есть (ассоциированные типы).


    1. Monnoroch
      03.01.2016 18:05

      Проблема еще в том, что если их сделают, придется здорово перелопатить стандартную либу, если захочется красиво и идиоматично.
      А они ее уже преждевременно стабилизировали напрочь.
      Сложная ситуация.


  1. stepik777
    03.01.2016 17:58

    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. kstep
      03.01.2016 18:16
      +1

      Да, конечно. Но тут не хотелось бы заострять внимание на deref coercion, так как речь не про неё, а про повторяющиеся преобразования.
      Тут специально делается акцент на происходящих переходах значение-ссылка-значение, поэтому выбран самый явный метод.