Язык программирования Rust
Скачать 7.02 Mb.
|
Метки циклов для устранения неоднозначности между несколькими циклами Если у вас есть циклы внутри циклов, break и continue применяются к самому внутреннему циклу в этой цепочке. При желании вы можете создать метку цикла, которую вы затем сможете использовать с break или continue для указания, что эти ключевые слова применяются к помеченному циклу, а не к самому внутреннему циклу. Метки цикла должны начинаться с одинарной кавычки. Вот пример с двумя вложенными циклами: fn main () { let mut counter = 0 ; let result = loop { counter += 1 ; if counter == 10 { break counter * 2 ; } }; println! ( "The result is {result}" ); } Внешний цикл имеет метку 'counting_up , и он будет считать от 0 до 2. Внутренний цикл без метки ведёт обратный отсчёт от 10 до 9. Первый break , который не содержит метку, выйдет только из внутреннего цикла. Оператор break 'counting_up; завершит внешний цикл. Этот код напечатает: Циклы с условием while В программе часто требуется проверить состояние условия в цикле. Пока условие истинно, цикл выполняется. Когда условие перестаёт быть истинным, программа вызывает break , останавливая цикл. Такое поведение можно реализовать с помощью комбинации loop , if , else и break . При желании попробуйте сделать это в программе. Это настолько распространённый паттерн, что в Rust реализована встроенная языковая конструкция для него, называемая цикл while . В листинге 3-3 мы используем while , чтобы выполнить три цикла программы, производя каждый раз обратный отсчёт, а затем, после завершения цикла, печатаем сообщение и выходим. fn main () { let mut count = 0 ; 'counting_up : loop { println! ( "count = {count}" ); let mut remaining = 10 ; loop { println! ( "remaining = {remaining}" ); if remaining == 9 { break ; } if count == 2 { break 'counting_up ; } remaining -= 1 ; } count += 1 ; } println! ( "End count = {count}" ); } $ cargo run Compiling loops v0.1.0 (file:///projects/loops) Finished dev [unoptimized + debuginfo] target(s) in 0.58s Running `target/debug/loops` count = 0 remaining = 10 remaining = 9 count = 1 remaining = 10 remaining = 9 count = 2 remaining = 10 End count = 2 Имя файла: src/main.rs Листинг 3-3: Использование цикла while для выполнения кода, пока условие истинно Эта конструкция устраняет множество вложений, которые потребовались бы при использовании loop , if , else и break , и она более понятна. Пока условие истинно, код выполняется, в противном случае происходит выход из цикла. Цикл по элементам коллекции с помощью for Для перебора элементов коллекции, например, массива, можно использовать конструкцию while . Например, цикл в листинге 3-4 печатает каждый элемент массива a Имя файла: src/main.rs Листинг 3-4: Перебор каждого элемента коллекции с помощью цикла while Этот код выполняет перебор элементов массива. Он начинается с индекса 0 , а затем циклически выполняется, пока не достигнет последнего индекса в массиве (то есть, когда index < 5 уже не является истиной). Выполнение этого кода напечатает каждый элемент массива: fn main () { let mut number = 3 ; while number != 0 { println! ( "{number}!" ); number -= 1 ; } println! ( "LIFTOFF!!!" ); } fn main () { let a = [ 10 , 20 , 30 , 40 , 50 ]; let mut index = 0 ; while index < 5 { println! ( "the value is: {}" , a[index]); index += 1 ; } } Все пять значений массива появляются в терминале, как и ожидалось. Поскольку index в какой-то момент достигнет значения 5 , цикл прекратит выполнение перед попыткой извлечь шестое значение из массива. Однако такой подход чреват ошибками. Можно вызвать панику в программе, если значение индекса или условие теста будут неверны. Например, если изменить определение массива a на четыре элемента, но забыть обновить условие на while index < 4 , код вызовет панику. Также это медленно, поскольку компилятор добавляет код времени выполнения для обеспечения проверки нахождения индекса в границах массива на каждой итерации цикла. В качестве более краткой альтернативы можно использовать цикл for и выполнять некоторый код для каждого элемента коллекции. Цикл for может выглядеть как код в листинге 3-5. Имя файла: src/main.rs Листинг 3-5: Перебор каждого элемента коллекции с помощью цикла for При выполнении этого кода мы увидим тот же результат, что и в листинге 3-4. Что важнее, теперь мы повысили безопасность кода и устранили вероятность ошибок, которые могут возникнуть в результате выхода за пределы массива или недостаточно далёкого перехода и пропуска некоторых элементов. При использовании цикла for не нужно помнить о внесении изменений в другой код, в случае изменения количества значений в массиве, как это было бы с методом, использованным в листинге 3-4. Безопасность и компактность циклов for делают их наиболее часто используемой конструкцией цикла в Rust. Даже в ситуациях необходимости выполнения некоторого кода определённое количество раз, как в примере обратного отсчёта, в котором $ cargo run Compiling loops v0.1.0 (file:///projects/loops) Finished dev [unoptimized + debuginfo] target(s) in 0.32s Running `target/debug/loops` the value is: 10 the value is: 20 the value is: 30 the value is: 40 the value is: 50 fn main () { let a = [ 10 , 20 , 30 , 40 , 50 ]; for element in a { println! ( "the value is: {element}" ); } } использовался цикл while из Листинга 3-3, большинство Rustaceans использовали бы цикл for . Для этого можно использовать Range , предоставляемый стандартной библиотекой, который генерирует последовательность всех чисел, начиная с первого числа и заканчивая вторым числом, но не включая его (т.е. (1..4) эквивалентно [1, 2, 3] или в общем случае (start..end) эквивалентно [start, start+1, start+2, ... , end-2, end-1] - прим.переводчика). Вот как будет выглядеть обратный отсчёт с использованием цикла for и другого метода, о котором мы ещё не говорили, rev , для разворота диапазона: Имя файла: src/main.rs Данный код выглядит лучше, не так ли? Итоги Вы справились! Это была объёмная глава: вы узнали о переменных, скалярных и составных типах данных, функциях, комментариях, выражениях if и циклах! Для практики работы с концепциями, обсуждаемыми в этой главе, попробуйте создать программы для выполнения следующих действий: Конвертация температур между значениями по Фаренгейту к Цельсия. Генерирование n-го числа Фибоначчи. Распечатайте текст рождественской песни "Двенадцать дней Рождества", воспользовавшись повторами в песне. Когда вы будете готовы двигаться дальше, мы поговорим о концепции в Rust, которая не существует обычно в других языках программирования: владение. fn main () { for number in ( 1 4 ).rev() { println! ( "{number}!" ); } println! ( "LIFTOFF!!!" ); } Понимание Владения Владение - это самая уникальная особенность Rust, которая имеет глубокие последствия для всего языка. Это позволяет Rust обеспечивать безопасность памяти без использования сборщика мусора, поэтому важно понимать, как работает владение. В этой главе мы поговорим о владении, а также о нескольких связанных с ним возможностях: заимствовании, срезах и о том, как Rust раскладывает данные в памяти. Что такое владение? Владение — это набор правил, определяющих, как программа на Rust управляет памятью. Все программы так или иначе должны использовать память компьютера во время работы. В некоторых языках есть сборщики мусора, которые регулярно отслеживают неиспользуемую память во время работы программы; в других программист должен память явно выделять и освобождать. В Rust используется третий подход: управление памятью происходит через систему владения с набором правил, которые проверяются компилятором. При нарушении любого из правил программа не будет скомпилирована. Ни одна из особенностей владения не замедлит работу вашей программы. Поскольку владение является новой концепцией для многих программистов, требуется некоторое время, чтобы привыкнуть к ней. Хорошая новость заключается в том, что чем больше у вас будет опыта с Rust и с правилами системы владения, тем легче вам будет естественным образом разрабатывать безопасный и эффективный код. Держитесь! Не сдавайтесь! Понимание концепции владения даст вам основу для понимания всех остальных особенностей, делающих Rust уникальным. В этой главе вы изучите владение на примерах, которые сфокусированы на наиболее часто используемой структуре данных: строках. Стек и куча Многие языки программирования не требуют, чтобы вы слишком часто думали о стеке и куче. Но в языках системного программирования, одним из которых является Rust, то, находится значение в стеке или в куче, влияет на поведение языка и на принятие вами определённых решений. Владение будет описано через призму стека и кучи позже в этой главе, а пока — краткое пояснение. И стек, и куча — это части памяти, доступные вашему коду для использования во время выполнения. Однако они структурированы по-разному. Стек хранит значения в порядке их получения, а удаляет — в обратном. Это называется «последний пришёл, первый вышел». Подумайте о стопке тарелок: когда вы добавляете тарелки, вы кладёте их сверху стопки — когда вам нужна тарелка, вы берёте одну так же сверху. Добавление или удаление тарелок посередине или снизу не сработает! Добавление данных называется помещением в стек, а удаление — извлечением из стека. Все данные, хранящиеся в стеке, должны иметь известный фиксированный размер. Данные, размер которых во время компиляции неизвестен или может измениться, должны храниться в куче. Куча менее организованна: когда вы помещаете данные в кучу, вы запрашиваете определённое место для их хранения. Распределитель памяти находит подходящее пустое место в куче, помечает его как используемое и возвращает указатель — адрес этого места. Этот процесс называется выделением в куче и иногда сокращённо просто выделением (помещение значений в стек не считается выделением). Поскольку указатель на кучу имеет известный фиксированный размер, вы можете хранить указатель в стеке, но когда вам нужны фактические данные, вы должны следовать за указателем. Представьте, что вы сидите в ресторане. Когда вы входите, вы называете количество человек в вашей группе, и персонал находит свободный стол, который подходит всем, и ведёт вас туда. Если кто-то из вашей группы опоздает, он может спросить, где вы сидели, чтобы найти вас. Размещение в стек происходит быстрее, чем выделение в куче, потому что операционная система не тратит время на поиски места для хранения данных. Местом размещения всегда является верхушка стека. Выделение памяти в куче требует больше работы, потому что операционная система должна сначала найти достаточно большой участок памяти для хранения данных и затем выполнить резервирование, чтобы подготовится к следующему выделению. Доступ к данным в куче медленнее, чем доступ к данным в стеке, потому что вам нужно следовать по адресу указателя, чтобы добраться туда. Современные процессоры работают быстрее, если они меньше прыгают по памяти. Продолжая аналогию, рассмотрим официанта в ресторане, принимающего заказы со многих столов. Наиболее эффективно будет получить все заказы за одним столом, прежде чем переходить к следующему столу. Получение заказа из таблицы А, затем из таблицы В, затем снова одного из А, а затем снова одного из В было бы гораздо более медленным делом. Точно так же процессор может выполнять свою работу лучше, если он работает с данными, которые находятся близко к другим данным (как в стеке), а не дальше (как это может быть в куче). Когда ваш код вызывает функцию, значения, переданные в неё (потенциально включающие указатели на данные в куче), и локальные переменные помещаются в стек. Когда функция завершается, эти значения извлекаются из стека. Отслеживание того, какие части кода используют какие данные, минимизация количества дублирующихся данных и очистка неиспользуемых данных в куче, чтобы не исчерпать пространство, — все эти проблемы решает владение. Как только вы поймёте, что такое владение, вам не нужно будет слишком часто думать о стеке и куче. Однако знание того, что основная цель владения — управление данными кучи, может помочь объяснить, почему оно работает именно так. Правила владения Во-первых, давайте взглянем на правила владения. Помните об этих правилах, пока мы работаем с примерами, которые их иллюстрируют: У каждого значения в Rust есть владелец, У значения может быть только один владелец в один момент времени, Когда владелец покидает область видимости, значение удаляется. Область видимости переменной Теперь, когда мы прошли базовый синтаксис Rust, мы не будем включать весь код fn main() { в примеры. Поэтому, если вы будете следовать этому курсу, убедитесь, что следующие примеры помещены в функцию main вручную. В результате наши примеры будут более лаконичными, что позволит нам сосредоточиться на реальных деталях, а не на шаблонном коде. В качестве первого примера владения мы рассмотрим область видимости некоторых переменных. Область видимости — это диапазон внутри программы, для которого допустим элемент. Возьмём следующую переменную: Переменная s относится к строковому литералу, где значение строки жёстко прописано в тексте нашей программы. Переменная действительна с момента её объявления до конца текущей области видимости. В листинге 4-1 показана программа с комментариями, указывающими, где допустима переменная s Листинг 4-1: переменная и область действия, в которой она допустима Другими словами, здесь есть два важных момента: Когда переменная s появляется в области видимости, она считается действительной, Она остаётся действительной до момента выхода за границы этой области. На этом этапе объяснения взаимосвязь между областями видимости и допустимостью переменных аналогична той, что существует в других языках программирования. Теперь мы будем опираться на это понимание, введя тип String Тип данных String Чтобы проиллюстрировать правила владения, нам нужен тип данных более сложный, чем те, которые мы рассмотрели в разделе «Типы данных» главы 3. Все рассмотренные let s = "hello" ; { // s is not valid here, it’s not yet declared let s = "hello" ; // s is valid from this point forward // do stuff with s } // this scope is now over, and s is no longer valid ранее типы имеют известный размер, могут храниться в стеке и извлекаться из стека, когда их область действия заканчивается. Также они могут быть быстро и легко скопированы для создания нового независимого экземпляра, если другая часть кода должна использовать то же значение в другой области видимости. Но мы хотим посмотреть на данные, хранящиеся в куче, и выяснить, как Rust узнает, когда нужно очистить эти данные, поэтому тип String — отличный пример. Мы сконцентрируемся на тех частях String , которые связаны с владением. Эти аспекты также применимы к другим сложным типам данных, независимо от того, предоставлены они стандартной библиотекой или созданы вами. Более подробно мы обсудим String в главе 8 Мы уже видели строковые литералы, где строковое значение жёстко прописано в нашей программе. Строковые литералы удобны, но они подходят не для каждой ситуации, где мы можем хотеть использовать текст. Одна из причин заключается в том, что они неизменны. Кроме того, не каждое строковое значение может быть известно во время написания кода: что, если мы захотим принять и сохранить пользовательский ввод? Для таких ситуаций в Rust есть ещё один строковый тип — String . Этот тип управляет данными, выделенными в куче, и поэтому может хранить объём текста, который во время компиляции неизвестен. Также вы можете создать String из строкового литерала, используя функцию from , например: Оператор двойного двоеточия :: позволяет нам использовать пространство имён функции from под типом String , вместо какого-то имени вроде string_from . Мы обсудим этот синтаксис более подробно в разделе «Синтаксис метода» главы 5 и когда мы будем говорить о пространствах имён с модулями в «Пути для обращения к элементу в дереве модулей» в главе 7. Строка такого типа может быть изменяема: В чем здесь разница? Почему String можно менять, а литерал — нельзя? Разница в том, как эти два типа работают с памятью. Память и способы её выделения В случае строкового литерала мы знаем его содержимое во время компиляции, и оно жёстко прописано в итоговом исполняемом файле. Причина того, что строковые let s = String ::from( "hello" ); let mut s = String ::from( "hello" ); s.push_str( ", world!" ); // push_str() appends a literal to a String println! ( "{}" , s); // This will print `hello, world!` литералы более быстрые и эффективные, в их неизменяемости. К сожалению, нельзя поместить неопределённый кусок памяти в выполняемый файл для текста, размер которого неизвестен при компиляции и может меняться во время выполнения программы. Чтобы поддерживать изменяемый, увеличивающийся текст типа String , необходимо выделять память в куче для всего содержимого, объем которого неизвестен во время компиляции. Это означает, что: Память должна запрашиваться у операционной системы во время выполнения программы, Необходим способ возврата этой памяти операционной системе, когда мы закончили в программе работу со String Первая часть выполняется нами: когда мы вызываем String::from , его реализация запрашивает необходимую память. Это работает довольно похоже во всех языках программирования. Однако вторая часть отличается. В языках со сборщиком мусора (GC), память, которая больше не используется, отслеживается и очищается с его помощью — нам не нужно об этом думать. В большинстве языков без сборщика мусора мы обязаны сами определять, когда память больше не используется, и вызывать код для явного её освобождения, точно так же, как мы делали это для её запроса. Правильное выполнение этого процесса исторически было сложной проблемой программирования. Если мы забудем освободить память, она будет потеряна. Если мы сделаем это слишком рано, у нас будет недопустимая переменная. Сделать это дважды тоже будет ошибкой. Нам нужно соединить ровно один allocate ровно с одним free Rust выбирает другой путь: память автоматически возвращается, как только владеющая памятью переменная выходит из области видимости. Вот версия примера с областью видимости из листинга 4-1, в котором используется тип String вместо строкового литерала: Существует естественный момент, когда мы можем вернуть память, необходимую нашему String , обратно распределителю — когда s выходит за пределы области видимости. Когда переменная выходит за пределы области видимости, Rust вызывает для нас специальную функцию. Эта функция называется drop , и именно здесь автор String может поместить код для возврата памяти. Rust автоматически вызывает drop после закрывающей фигурной скобки. { let s = String ::from( "hello" ); // s is valid from this point forward // do stuff with s } // this scope is now over, and s is no // longer valid Примечание: в C++ этот паттерн освобождения ресурсов в конце времени жизни элемента иногда называется «Получение ресурса есть инициализация» (англ. Resource Acquisition Is Initialization (RAII)). Функция drop в Rust покажется вам знакомой, если вы использовали шаблоны RAII. Этот шаблон оказывает глубокое влияние на способ написания кода в Rust. Сейчас это может казаться простым, но в более сложных ситуациях поведение кода может быть неожиданным, например когда хочется иметь несколько переменных, использующих данные, выделенные в куче. Изучим несколько таких ситуаций. |