Язык программирования Rust
Скачать 7.02 Mb.
|
Несколько блоков impl Каждая структура может иметь несколько impl . Например, Листинг 5-15 эквивалентен коду, показанному в листинге 5-16, в котором каждый метод находится в своём собственном блоке impl impl Rectangle { fn square (size: u32 ) -> Self { Self { width: size, height: size, } } } impl Rectangle { fn area (& self ) -> u32 { self .width * self .height } } impl Rectangle { fn can_hold (& self , other: &Rectangle) -> bool { self .width > other.width && self .height > other.height } } Листинг 5-16: Переписанный Листинга 5-15 с использованием нескольких impl Здесь нет причин разделять методы на несколько impl , но это допустимый синтаксис. Мы увидим случай, когда несколько impl могут оказаться полезными, в Главе 10, рассматривающей обобщённые типы и свойства. Итоги Структуры позволяют создавать собственные типы, которые имеют смысл в вашей предметной области. Используя структуры, вы храните ассоциированные друг с другом фрагменты данных и даёте название частям данных, чтобы ваш код был более понятным. Методы позволяют определить поведение, которое имеют экземпляры ваших структур, а ассоциированные функции позволяют привязать функциональность к вашей структуре, не обращаясь к её экземпляру. Но структуры — не единственный способ создавать собственные типы: давайте обратимся к перечислениям в Rust, чтобы добавить ещё один инструмент в свой арсенал. Перечисления и сопоставление с образцом В этой главе мы рассмотрим перечисления (enumerations), также называемые enums. Перечисления позволяют определять типы путём перечисления их возможных вариантов. Во-первых, мы определим и используем перечисление, чтобы показать, как оно может объединить значения и данные. Далее мы рассмотрим особенно полезное перечисление Option , которое указывает, что значение может быть или чем-то, или ничем. Затем мы посмотрим, как сопоставление шаблонов в выражении match позволяет легко запускать разный код для разных значений перечислений. Наконец, мы узнаем, насколько конструкция if let удобна и лаконична для обработки перечислений в вашем коде. Определение перечисления Там, где структуры дают вам возможность группировать связанные поля и данные, например Rectangle с его width и height , перечисления дают вам способ сказать, что значение является одним из возможных наборов значений. Например, мы можем захотеть сказать, что Rectangle — это одна из множества возможных фигур, в которую также входят Circle и Triangle . Для этого Rust позволяет нам закодировать эти возможности в виде перечисления. Давайте рассмотрим ситуацию, которую мы могли бы захотеть отразить в коде, и поймём, почему перечисления полезны и более уместны, чем структуры в этом случае. Допустим, нам нужно работать с IP-адресами. В настоящее время для обозначения IP- адресов используются два основных стандарта: четвёртая и шестая версии. Поскольку это единственно возможные варианты IP-адресов, с которыми может столкнуться наша программа, мы можем перечислить все возможные варианты, откуда перечисление и получило своё название. Любой IP-адрес может быть либо четвёртой, либо шестой версии, но не обеими одновременно. Эта особенность IP-адресов делает структуру данных enum подходящей, поскольку значение enum может представлять собой только один из его возможных вариантов. Адреса как четвёртой, так и шестой версии по своей сути все равно являются IP-адресами, поэтому их следует рассматривать как один и тот же тип, когда в коде обрабатываются задачи, относящиеся к любому типу IP-адресов. Можно выразить эту концепцию в коде, определив перечисление IpAddrKind и составив список возможных видов IP-адресов, V4 и V6 . Вот варианты перечислений: IpAddrKind теперь является пользовательским типом данных, который мы можем использовать в другом месте нашего кода. Значения перечислений Экземпляры каждого варианта перечисления IpAddrKind можно создать следующим образом: Обратите внимание, что варианты перечисления находятся в пространстве имён вместе с его идентификатором, а для их обособления мы используем двойное двоеточие. Это enum IpAddrKind { V4, V6, } let four = IpAddrKind::V4; let six = IpAddrKind::V6; удобно тем, что теперь оба значения IpAddrKind::V4 и IpAddrKind::V6 относятся к одному типу: IpAddrKind . Затем мы можем, например, определить функцию, которая принимает любой из вариантов IpAddrKind : Можно вызвать эту функцию с любым из вариантов: Использование перечислений позволяет получить ещё больше преимуществ. Если подумать о нашем типе для IP-адреса, то выяснится, что на данный момент у нас нет возможности хранить собственно сам IP-адрес; мы будем знать только его тип. Учитывая, что недавно в главе 5 вы узнали о структурах, у вас может возникнуть соблазн решить эту проблему с помощью структур, как показано в листинге 6-1. Листинг 6-1. Сохранение данных и IpAddrKind IP-адреса с использованием struct Здесь мы определили структуру IpAddr , у которой есть два поля: kind типа IpAddrKind (перечисление, которое мы определили ранее) и address типа String . У нас есть два экземпляра этой структуры. Первый - home , который является IpAddrKind::V4 в качестве значения kind с соответствующим адресом 127.0.0.1 . Второй экземпляр - loopback . Он в качестве значения kind имеет другой вариант IpAddrKind , V6 , и с ним ассоциирован адрес ::1 . Мы использовали структуру для объединения значений kind и address вместе, таким образом тип формата адреса теперь ассоциирован со значением. Однако представление этой же концепции с помощью перечисления более лаконично: вместо того, чтобы помещать перечисление в структуру, мы можем поместить данные непосредственно в любой из вариантов перечисления. Это новое определение fn route (ip_kind: IpAddrKind) {} route(IpAddrKind::V4); route(IpAddrKind::V6); enum IpAddrKind { V4, V6, } struct IpAddr { kind: IpAddrKind, address: String , } let home = IpAddr { kind: IpAddrKind::V4, address: String ::from( "127.0.0.1" ), }; let loopback = IpAddr { kind: IpAddrKind::V6, address: String ::from( "::1" ), }; перечисления IpAddr гласит, что оба варианта V4 и V6 будут иметь соответствующие значения String : Мы прикрепляем данные к каждому варианту перечисления напрямую, поэтому нет необходимости в дополнительной структуре. Здесь также легче увидеть ещё одну деталь того, как работают перечисления: имя каждого варианта перечисления, который мы определяем, также становится функцией, которая создаёт экземпляр перечисления. То есть IpAddr::V4() - это вызов функции, который принимает String и возвращает экземпляр типа IpAddr . Мы автоматически получаем эту функцию-конструктор, определяемую в результате определения перечисления. Ещё одно преимущество использования перечисления вместо структуры заключается в том, что каждый вариант перечисления может иметь разное количество ассоциированных данных представленных в разных типах. Версия 4 для типа IP адресов всегда будет содержать четыре цифровых компонента, которые будут иметь значения между 0 и 255. При необходимости сохранить адреса типа V4 как четыре значения типа u8 , а также описать адреса типа V6 как единственное значение типа String , мы не смогли бы с помощью структуры. Перечисления решают эту задачу легко: Мы показали несколько различных способов определения структур данных для хранения IP-адресов четвёртой и шестой версий. Однако, как оказалось, желание хранить IP-адреса и указывать их тип настолько распространено, что в стандартной библиотеке есть определение, которое мы можем использовать! Давайте посмотрим, как стандартная библиотека определяет IpAddr : в ней есть точно такое же перечисление с вариантами, которое мы определили и использовали, но она помещает данные об адресе внутрь этих вариантов в виде двух различных структур, которые имеют различные определения для каждого из вариантов: enum IpAddr { V4( String ), V6( String ), } let home = IpAddr::V4( String ::from( "127.0.0.1" )); let loopback = IpAddr::V6( String ::from( "::1" )); enum IpAddr { V4( u8 , u8 , u8 , u8 ), V6( String ), } let home = IpAddr::V4( 127 , 0 , 0 , 1 ); let loopback = IpAddr::V6( String ::from( "::1" )); Этот код иллюстрирует что мы можем добавлять любой тип данных в значение перечисления: строку, число, структуру и пр. Вы даже можете включить в перечисление другие перечисления! Стандартные типы данных не очень сложны, хотя, потенциально, могут быть очень сложными (вложенность данных может быть очень глубокой). Обратите внимание, что хотя определение перечисления IpAddr есть в стандартной библиотеке, мы смогли объявлять и использовать свою собственную реализацию с аналогичным названием без каких-либо конфликтов, потому что мы не добавили определение стандартной библиотеки в область видимости кода. Подробнее об этом поговорим в Главе 7. Рассмотрим другой пример перечисления в листинге 6-2: в этом примере каждый элемент перечисления имеет свой особый тип данных внутри: Листинг 6-2. Перечисление Message , в каждом из вариантов которого хранятся разные количества и типы значений. Это перечисление имеет 4 элемента: Quit - пустой элемент без ассоциированных данных, Move имеет именованные поля, как и структура. Write - элемент с единственной строкой типа String , ChangeColor - кортеж из трёх значений типа i32 Определение перечисления с вариантами, такими как в листинге 6-2, похоже на определение значений различных типов внутри структур, за исключением того, что перечисление не использует ключевое слово struct и все варианты сгруппированы внутри типа Message . Следующие структуры могут содержать те же данные, что и предыдущие варианты перечислений: struct Ipv4Addr { // --snip-- } struct Ipv6Addr { // --snip-- } enum IpAddr { V4(Ipv4Addr), V6(Ipv6Addr), } enum Message { Quit, Move { x: i32 , y: i32 }, Write( String ), ChangeColor( i32 , i32 , i32 ), } Но когда мы использовали различные структуры, которые имеют свои собственные типы, мы не могли легко определять функции, которые принимают любые типы сообщений, как это можно сделать с помощью перечисления типа Message , объявленного в листинге 6-2, который является единым типом. Есть ещё одно сходство между перечислениями и структурами: так же, как мы можем определять методы для структур с помощью impl блока, мы можем определять и методы для перечисления. Вот пример метода с именем call , который мы могли бы определить в нашем перечислении Message : В теле метода будет использоваться self для получения значение того объекта, у которого мы вызвали этот метод. В этом примере мы создали переменную m , содержащую значение Message::Write(String::from("hello")) , и именно это значение будет представлять self в теле метода call при выполнении m.call() Теперь посмотрим на другое наиболее часто используемое перечисление из стандартной библиотеки, которое является очень распространённым и полезным: Option Перечисление Option и его преимущества перед Null-значениями В этом разделе рассматривается пример использования Option , ещё одного перечисления, определённого в стандартной библиотеке. Тип Option кодирует очень распространённый сценарий, в котором значение может быть чем-то, а может быть ничем. Например, если вы запросите первое значение из списка, содержащего элементы, вы получите значение. Если вы запросите первое значение из пустого списка, вы ничего не получите. Выражение этой концепции в терминах системы типов означает, что компилятор может проверить, обработали ли вы все случаи, которые должны были struct QuitMessage ; // unit struct struct MoveMessage { x: i32 , y: i32 , } struct WriteMessage ( String ); // tuple struct struct ChangeColorMessage ( i32 , i32 , i32 ); // tuple struct impl Message { fn call (& self ) { // method body would be defined here } } let m = Message::Write( String ::from( "hello" )); m.call(); обработать; эта функциональность может предотвратить ошибки, которые чрезвычайно распространены в других языках программирования. Дизайн языка программирования часто рассматривается с точки зрения того, какие функции вы включаете в него, но те функции, которые вы исключаете, также важны. Например в Rust нет такого функционала как null значения, однако он есть во многих других языках. Null значение - это значение, которое означает, что значения нет. В языках с null значением переменные всегда могут находиться в одном из двух состояний: нет значения (null) или есть значение (not-null). В своей презентации 2009 года «Null ссылки: ошибка в миллиард долларов» Тони Хоар (Tony Hoare), изобретатель null, сказал следующее: Я называю это своей ошибкой на миллиард долларов. В то время я разрабатывал первую комплексную систему типов для ссылок на объектно-ориентированном языке. Моя цель состояла в том, чтобы гарантировать, что любое использование ссылок должно быть абсолютно безопасным, с автоматической проверкой компилятором. Но я не мог устоять перед соблазном вставить пустую ссылку просто потому, что это было так легко реализовать. Это привело к бесчисленным ошибкам, уязвимостям и системным сбоям, которые, вероятно, причинили боль и ущерб на миллиард долларов за последние сорок лет. Проблема с null значениями заключается в том, что если вы попытаетесь использовать null значение в качестве not-null значения, вы получите ошибку определённого рода. Поскольку свойство null или not-null распространено повсеместно, сделать такую ошибку очень просто. Тем не менее, концепция, которую null пытается выразить, является полезной: null - это значение, которое в настоящее время по какой-то причине недействительно или отсутствует. Проблема с null значениями заключается в том, что если вы попытаетесь использовать null значение как не-null значение, вы получите какую-то ошибку. Поскольку это свойство null или не-null широко распространено, очень легко совершить такую ошибку. Перечисление Option настолько полезно, что оно даже включено в прелюдию; вам не нужно явно вводить его в область видимости. Его варианты также включены в прелюдию: вы можете использовать Some и None напрямую, без префикса Option:: При всём при этом, Option является обычным перечислением, а Some(T) и None представляют собой его варианты. enum Option None , Some (T), } Проблема не в самой концепции, а в конкретной реализации. Таким образом, в Rust нет null-значений, но есть перечисление, которое может закодировать концепцию наличия или отсутствия значения. Это перечисление Option и оно определено в стандартной библиотеке следующим образом: Тип some_number - Option . Тип some_string - Option<&str> , это другой тип. Rust может вывести эти типы, потому что мы указали значение внутри варианта Some . Для absent_number Rust требует, чтобы мы аннотировали общий тип для Option : компилятор не может вывести тип, который будет в Some , глядя только на значение None . Здесь мы сообщаем Rust, что absent_number должен иметь тип Option Когда есть значение Some , мы знаем, что значение присутствует и содержится внутри Some . Когда есть значение None , это означает то же самое, что и null в некотором смысле: у нас нет действительного значения. Так почему наличие Option лучше, чем null? Вкратце, поскольку Option и T (где T может быть любым типом) относятся к разным типам, компилятор не позволит нам использовать значение Option даже если бы оно было определённо допустимым вариантом Some . Например, этот код не будет компилироваться, потому что он пытается добавить i8 к значению типа Option : Запуск данного кода даст ошибку ниже: let some_number = Some ( 5 ); let some_char = Some ( 'e' ); let absent_number: Option < i32 > = None ; let x: i8 = 5 ; let y: Option < i8 > = Some ( 5 ); let sum = x + y; Сильно! Фактически, это сообщение об ошибке означает, что Rust не понимает, как сложить i8 и Option , потому что это разные типы. Когда у нас есть значение типа на подобие i8 , компилятор гарантирует, что у нас всегда есть допустимое значение типа. Мы можем уверенно продолжать работу, не проверяя его на null перед использованием. Однако, когда у нас есть значение типа Option (где T - это любое значение любого типа T , упакованное в Option , например значение типа i8 или String ), мы должны беспокоиться о том, что значение типа T возможно не имеет значения (является вариантом None ), и компилятор позаботится о том, чтобы мы обработали такой случай, прежде чем мы бы попытались использовать None значение. Другими словами, вы должны преобразовать Option в T прежде чем вы сможете выполнять операции с этим T . Как правило, это помогает выявить одну из наиболее распространённых проблем с null: предполагая, что что-то не равно null, когда оно на самом деле равно null. Устранение риска ошибочного предположения касательно не-null значения помогает вам быть более уверенным в своём коде. Чтобы иметь значение, которое может быть null, вы должны явно описать тип этого значения с помощью Option . Затем, когда вы используете это значение, вы обязаны явно обрабатывать случай, когда значение равно null. Везде, где значение имеет тип, отличный от Option , вы можете смело рассчитывать на то, что значение не равно null. Это продуманное проектное решение в Rust, ограничивающее распространение null и увеличивающее безопасность кода на Rust. Итак, как же получить значение T из варианта Some , если у вас на руках есть только объект Option , и как можно его, вообще, использовать? Перечисление Option имеет большое количество методов, полезных в различных ситуациях; вы можете $ cargo run Compiling enums v0.1.0 (file:///projects/enums) error[E0277]: cannot add `Option --> src/main.rs:5:17 | 5 | let sum = x + y; | ^ no implementation for `i8 + Option | = help: the trait `Add ознакомиться с ними в его документации . Знакомство с методами перечисления Option будет чрезвычайно полезным в вашем путешествии с Rust. В общем случае, чтобы использовать значение Option , нужен код, который будет обрабатывать все варианты перечисления Option . Вам понадобится некоторый код, который будет работать только тогда, когда у вас есть значение Some(T) , и этому коду разрешено использовать внутреннее T . Также вам понадобится другой код, который будет работать, если у вас есть значение None , и у этого кода не будет доступного значения T . Выражение match — это конструкция управления потоком выполнения программы, которая делает именно это при работе с перечислениями: она запускает разный код в зависимости от того, какой вариант перечисления имеется, и этот код может использовать данные, находящиеся внутри совпавшего варианта. |