Найти в Дзене
ZDG

Языки программирования 10: Rust

Rust внешне похож на привычные C-подобные языки, но внутри он совсем не таков. Поэтому те, кто легкомысленно пытаются его освоить, вскоре убивают себя об стену. Предыдущая часть: Rust это императивный язык, но схож с функциональными типа Haskell в том плане, что математически гарантирует корректность работы с памятью и указателями. Математические теории я лучше оставлю для объяснения более компетентными математиками. На более приземлённом уровне, Rust это как C, только придуманный садистом. Без сборщика мусора, но при этом облегчающий работу с памятью, так как она может выделяться и освобождаться полностью автоматически и корректно. Но за это надо платить – код надо писать специфическим образом. Если до этого я писал про языки, находя у каждого некую особенность, то у Rust особенное примерно всё. Поэтому трудно даже выбрать, о чём писать. Первое знакомство Все локальные переменные имеют блочную область видимости. К примеру, Переменная bar внутри любого блока { ... } живёт только там и
Оглавление

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

Предыдущая часть:

Rust это императивный язык, но схож с функциональными типа Haskell в том плане, что математически гарантирует корректность работы с памятью и указателями.

Математические теории я лучше оставлю для объяснения более компетентными математиками.

На более приземлённом уровне, Rust это как C, только придуманный садистом. Без сборщика мусора, но при этом облегчающий работу с памятью, так как она может выделяться и освобождаться полностью автоматически и корректно. Но за это надо платить – код надо писать специфическим образом.

Если до этого я писал про языки, находя у каждого некую особенность, то у Rust особенное примерно всё. Поэтому трудно даже выбрать, о чём писать.

Первое знакомство

Все локальные переменные имеют блочную область видимости. К примеру,

Переменная bar внутри любого блока { ... } живёт только там и недоступна снаружи. Это есть и в других языках, будь то C или JS. Механизм прост: когда начинается новый блок, на стеке резервируется место под переменные, которые объявляются в нём, а когда блок заканчивается, то место на стеке освобождается.

Отметим, что Rust строго, даже супер-строго типизированный язык, но в моём примере у переменной не указан тип. По идее, надо было написать так:

let foo: i32 = 5;

Но Rust, когда это очевидно, понимает тип переменной из того значения, которое ей присваивается.

Рассмотрим объявление функции test():

-2

Заметьте, что тип параметра i32 (целое 32 бита), функция тоже возвращает результат типа i32, но записывать это надо через стрелку, а не через двоеточие. Для примера функция просто возвращает значение аргумента x как результат. Заметьте, что отсутствует слово return. Вы можете написать:

return x;

А можете написать просто:

x

Без точки с запятой в конце, это важно.

Всё это пока хоть и "не как у всех", но ничего такого ужасного.

Владение

Напишем структуру Data и изменим типы для функции, чтобы работала со структурой и возвращала её же:

-3

Теперь инициализируем переменную foo как структуру типа Data и передадим её в функцию test():

-4

После этого попробуем распечатать значение foo.x. Посмотрите, во-первых, на println! с восклицательным знаком. Это макрос, а не функция. Ну то есть все языки как-то справляются, а тут, значит, макрос. Строка для вывода форматируется подобно printf() в C, но более просто: не надо писать %d, %s и т.д. для разных типов аргументов, пишем просто {} и туда подставляется очередной по порядку аргумент.

Но наша главная проблема в том, что теперь программа даже не скомпилируется. После того, как мы передали значение foo в функцию test(), переменная foo осталась, но... она потеряла своё значение, она им больше не владеет! Мы не можем напечатать foo.x, хотя совершенно очевидно, что ничто никуда не делось.

Концепция владения – основа Rust

Можно рассматривать структуру данных как некоторое состояние, которое может меняться. Менять его может только тот, кто им владеет. Кажется, здесь попахивает функциональным подходом и может даже какими-то там монадами, да?

Владение начинается с переменной foo, но после передачи в функцию test() этими данными начинает владеть функция. Функция возвращает данные обратно, и мы можем например отдать их переменной bar:

let bar = test(foo);

Теперь данными владеет bar, и т.д.

Я специально проверял ассемблерный код, сгенерированный компилятором, чтобы найти там что-то странное. Но ничего странного нет. Переменная foo остаётся на стеке, данные в ней не меняются, а копируются сначала в функцию test(), потом из функции test() в переменную bar. По сути произошло обычное копирование данных из одного места памяти в другое. Тем не менее, foo уже не владеет собственными данными. То есть это ограничение не физическое, а концептуальное.

Можно немного обойти это дело повторным присвоением foo:

foo = test(foo);

Тогда значение просто скопируется из foo в функцию, а оттуда опять в foo (да, прямо в тот же самый адрес памяти без пересоздания чего-либо, всё честно) и владение останется за foo. Правда, теперь foo надо объявлять как мутабельную, потому что по умолчанию переменные немутабельны:

let mut foo = Data { x: 5, y: 10 };

Мы можем также обойти ограничение владения, если явно разрешим копирование и клонирование. Для этого класс (будем уже говорить так) Data должен иметь трейт (что-то типа интерфейса) Copy и Clone. Их можно реализовать самостоятельно, но в Rust они уже "прошитые" и поэтому добавляются через директивы компилятора:

-5

Теперь при передаче в функцию значения переменная foo не теряет владения своими данными. Они не переместились, а скопировались. Но в ассемблерном коде при этом ничего не меняется, потому что оно и раньше точно так же копировалось через параметры функции. Как я и говорил, ограничение чисто концептуальное.

Зачем это надо? Данные, которыми владеет переменная, прекращают свое существование вместе с переменной. А когда прекращает существование переменная, мы уже знаем: когда заканчивается блок, в котором она объявлена. Таким образом, когда у данных только один владелец, всё становится однозначно. Ушёл владелец – ушли и данные.

Ну и для смены владельца данные не обязательно передавать куда-то, можно просто присвоить другой переменной:

-6

Этот код не скомпилируется по целым двум причинам, так как foo потеряла владение, а переменная bar, которая его получила, умерла.

Заимствования

Вместо передачи владения данные можно позаимствовать (borrow). В терминах C это вроде как получить указатель, но не совсем.

Заимствования могут быть немутабельные (& foo) и мутабельные (& mut foo).

Немутабельных может быть сколько угодно:

-7

Переменная, владеющая немутабельной ссылкой на чужие данные, может ими пользоваться, но не может их менять. Но и сам владелец, в данном случае foo, не может менять собственные данные, если кто-то владеет ссылкой на них. Такой код не скомпилируется:

-8

А такой – да, потому что переменные bar и baz успевают умереть вместе со ссылками.

-9

Мутабельная ссылка позволяет модифицировать данные. Но ею может владеть только кто-то один. И естественно, на это время хозяин не может вмешиваться.

-10

Это как бы временная передача владения, до смерти того, кто взял в долг.

Можно передать мутабельную ссылку в функцию:

-11

Функция успешно меняет значение по ссылке, и затем владение мутабельной ссылкой заканчивается. Выглядит как указатель в C, но...

Время жизни

Создадим новый тип данных, теперь с мутабельной ссылкой внутри:

-12

Создадим переменную foo такого типа и проинициализируем ссылку, взяв её у переменной bar.

Программа не компилируется!

Проблема в объявлении структуры Data. Она содержит ссылку на что-то, что будет известно лишь потом. Но Rust не хочет, чтобы это было известно потом. У переменной типа Data будет некое время жизни от момента создания до момента смерти. И у переменной, от которой Data возьмёт ссылку, тоже будет своё время жизни.

Так вот Rust хочет, чтобы мы дали гарантии, что переменная по ссылке в Data будет жить не меньше, чем живёт сама Data.

Для этого мы с помощью дженериков и уродливого синтаксиса должны пометить время жизни для каждого участника:

-13

В данном случае буква "a" это просто условное название времени жизни, и означает это всё следующее: если переменная с типом Data живёт время жизни "a", то ссылка внутри неё должна жить не меньше чем то же самое "a".

Вернёмся к примеру:

-14

Здесь переменная foo имеет время жизни внутри своего блока, которое становится тем самым "a". Переменная bar, от которой заимствуется ссылка, находится в том же блоке, следовательно её время жизни не меньше. Теперь программа скомпилируется.

Если же напишем так:

-15

То программа не скомпилируется, так как время жизни baz меньше, чем у foo.

Прочее

Вот, прошёлся буквально крупными мазками и по самым верхам, и кажется даже всё довольно просто. Но если углубляться и реально писать программу с множеством взаимосвязанных структур, вылезет куча проблем, а точнее говоря, не учтённых ранее обстоятельств, которые в других языках просто не замечались.

По оставшемуся:

  • ООП в Rust это методы, приписываемые к структурам. Плюс трейты, выполняющие роль множественного наследования и/или интерфейсов
  • Абсурдно сложные дженерики
  • Вы не можете вернуть NULL, всегда возвращается составной тип Some/None, Ok/Error и т.п.
  • Enum-типы, где можно перечислять целые структуры
  • Оператор match, работающий как switch, только по типам (что-то вроде рефлексии)
  • И многое другое

Заключение

  • Язык совсем не для новичков, ну вот просто совсем
  • На примерах всё хорошо, в реальных задачах – доводит до отчаяния, вгоняет в ступор
  • Есть, конечно, в этом какая-то красота, но жертв она требует серьёзных
  • Вероятно, это мостик к изучению функциональных языков