От переводчика
Статья — одна из серии постов, рассказывающих об использовании некоторых полезных библиотечных типажей и связанных с ними идиом 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)
fogone
03.01.2016 11:40а почему в new нельзя просто принимать Into без генерик-параметра?
fogone
03.01.2016 11:45простите, парсер съел. я имел ввиду:
а почему в new нельзя просто принимать тип без генерик-параметра? аля
fn new(name: Into<String>)
Monnoroch
03.01.2016 11:49Потому, что это не тип.
fogone
03.01.2016 11:55Да, это типаж. Т.е. типаж нельзя указать в качестве типа параметра функции?
Monnoroch
03.01.2016 12:00У переменной должен быть тип. Into<String\> — это не тип, это трейт. Следственно, запись
не имеет смысла.name: Into<String>
fogone
03.01.2016 12:10Т.е. у функции всегда точный тип параметра, только в случае с дженериком, для каждого использования компилятором будет создана своя функция с конкретным типом?
Monnoroch
03.01.2016 12:11Да, это называется мономорфизация. Есть и вариант с динамическим полиморфизмом, про него ниже написали.
kstep
03.01.2016 12:05Для полноты картины, некоторые типажи можно использовать в качестве типов (так называемые «типажи-объекты», а типажи, которые можно использовать как объекты называются «объектно безопасными» (object safe)), но только по указателям (вроде &Display), т.к. их размер не известен во время компиляции. Но типаж Into<T> к таким не относится, т.к. принимает тип-параметр.
Monnoroch
03.01.2016 12:10В этом случае типаж — это тоже не тип, а для каждого object safe-типажа неявно создается тип &TraitName, для которого авоматически создается реализация трейта TraitName, и этот тип — что-то вроде класса в ООП-языках, в обьектах этого типа будет таблица виртуальных функций, вместо мономорфизации.
«Указатель на трейт» — тоже бессмысленная фраза, так как трейт не является типом.vintage
03.01.2016 13:35Получается этакий алгебраический тип объединяющий все реализации типажа?
Monnoroch
03.01.2016 16:06Типа того, да! Как бы енум, но без паттернматчинга.
Но только логически. Фактически, я думаю, это указатель на кучу, под которым vptr и байты исходной структуры. Насколько я понимаю, обычно енумы делают через tagged union.
stepik777
03.01.2016 18:24У алгебраического типа данных заранее известны все варианты — здесь же что-то более похожее на интерфейс или абстрактный класс в ООП языках.
stepik777
03.01.2016 18:07TraitName — это тип с динамическим размером, аналогично str. На такой тип можно зоздавать разные указатели: — &TraitName, Box, Rc.
stepik777
03.01.2016 18:20поправка — &TraitName, Box<TraitName>, Rc<TraitName>.
kstep
03.01.2016 20:15А так же Arc<TraitName>, и вообще любой тип, реализующий Deref<Target=TraitName>.
Googolplex
03.01.2016 21:23Не любой. Насколько я помню, значение дженерикового типа должно быть последним элементом в структуре, чтобы быть unsized.
kstep
04.01.2016 12:25Здесь это не важно абсолютно, Deref<Target=T> преобразует заданный объект в &T, его метод `deref(&self) -> &Self::Target` принимает self по ссылке, поэтому unsized или sized тут Self и Self::Target не играет никакой роли.
kstep
04.01.2016 12:28Вот пользовательский тип может быть unsized только если unsized-поле единственное unsized поле и последнее в списке полей. Но только к трейту Deref и deref coercion это не имеет никакого отношения.
stepik777
03.01.2016 18:15Но типаж Into<T> к таким не относится, т.к. принимает тип-параметр.
Нет, тип-параметр тут не при чём, тут какое-то искусственное ограничение — у него зачем-то стоит ограничение Sized, а trait-object — это тип c динамическим размером, он не может быть Sized. Зачем это ограничение не знаю, возможно есть какой-то смысл.Googolplex
03.01.2016 21:24Into::into()
принимаетself
по значению, такие трейты не могут быть object-safe, следовательно, из них нельзя сделать трейт-объект.
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 по значению, так что использование такого типаж-объекта было бы абсолютно бессмысленно.
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
Пока их нет, не могу получить наслаждения от раста, если кодить что-то обобщенное.
Пока кодишь без дженериков — все круто, как только хочется чуть серьезнее, чем очень простые случаи с ними — все, грусть навевает.
kstep
03.01.2016 17:33+1Аналогично, тоже их очень жду. Пока просто обхожусь тем, что есть (ассоциированные типы).
Monnoroch
03.01.2016 18:05Проблема еще в том, что если их сделают, придется здорово перелопатить стандартную либу, если захочется красиво и идиоматично.
А они ее уже преждевременно стабилизировали напрочь.
Сложная ситуация.
stepik777
03.01.2016 17:58
Можно вместо name.as_ref() писать просто &name:let name = "Herman".to_string(); let person = Person::new(name.as_ref());
Это работает благодаря deref coercions.let name = "Herman".to_string(); let person = Person::new(&name);
kstep
03.01.2016 18:16+1Да, конечно. Но тут не хотелось бы заострять внимание на deref coercion, так как речь не про неё, а про повторяющиеся преобразования.
Тут специально делается акцент на происходящих переходах значение-ссылка-значение, поэтому выбран самый явный метод.
vintage
Зачем вообще в расте разделение на String и &str? Почему не используется один тип, как в остальных языках?
Monnoroch
Потому, что, String владеет буфером, а значит &String указывает на обьект владеющий буфером. Но статическим буфером (строковым литералом) никто владеть не может, так что для него придумали тип str, который сам по себе непонятно что собой представляет, но вот &str — это указатель на строковый литерал, что уже полезно.
Его можно было бы запихнуть и в &String, но тогда нужно делать каждый раз аллокацию/деаллокацию. Итого, N аллокаций по одной на каждый вызов кода со строковым литералом против нуля с &str. Профит!
Насчет лучше разбирается: я думаю, это культурное различие и автор хотел сделать код и обьяснение понятнее новичкам.
ozkriff
Может, через какое-то время эта оптимизация все-таки станет менее актуальной — https://github.com/rust-lang/rust/pull/30652
kstep
Возможно, когда-нибудь. Но пока так.
stepik777
Как специализация здесь поможет?
Googolplex
Сейчас метод
to_string()
определёт в трейтеToString
, у которого есть blanket-реализация для всехDisplay
-типов:Соответственно, для
String
/&str
также используетсяformat!()
, что неэффективно.Как только специализация будет доступна, можно будет переопределить
ToString
дляstr
черезString::from_str()
. Без специализации сделать такую реализацию нельзя, потому что она будет конфликтовать с blanket-реализацией дляT: Display
.stack_trace
Вообще говоря, вы не правы. Rust многие (в том числе я), видят правопреемника C++. Так вот, в C++ есть некоторый аналог среза, когда в функцию надо передать часть строки без копирования. Правда делается это, обычно, двумя параметрами — либо указателем на начало строки и длиной строки, либо двумя итераторами. Первый способ считается более «сишным» и несколько менее красивым. В Rust просто создали отдельную сущность для этого, называемую срезом, у которой внутри всё те же два параметра. Плюс, всё это отлично легло на концепцию владения (которая и так давно есть в языках типа С и С++, просто не поддерживается на уровне языка).