Rust внешне похож на привычные C-подобные языки, но внутри он совсем не таков. Поэтому те, кто легкомысленно пытаются его освоить, вскоре убивают себя об стену.
Предыдущая часть:
Rust это императивный язык, но схож с функциональными типа Haskell в том плане, что математически гарантирует корректность работы с памятью и указателями.
Математические теории я лучше оставлю для объяснения более компетентными математиками.
На более приземлённом уровне, Rust это как C, только придуманный садистом. Без сборщика мусора, но при этом облегчающий работу с памятью, так как она может выделяться и освобождаться полностью автоматически и корректно. Но за это надо платить – код надо писать специфическим образом.
Если до этого я писал про языки, находя у каждого некую особенность, то у Rust особенное примерно всё. Поэтому трудно даже выбрать, о чём писать.
Первое знакомство
Все локальные переменные имеют блочную область видимости. К примеру,
Переменная bar внутри любого блока { ... } живёт только там и недоступна снаружи. Это есть и в других языках, будь то C или JS. Механизм прост: когда начинается новый блок, на стеке резервируется место под переменные, которые объявляются в нём, а когда блок заканчивается, то место на стеке освобождается.
Отметим, что Rust строго, даже супер-строго типизированный язык, но в моём примере у переменной не указан тип. По идее, надо было написать так:
let foo: i32 = 5;
Но Rust, когда это очевидно, понимает тип переменной из того значения, которое ей присваивается.
Рассмотрим объявление функции test():
Заметьте, что тип параметра i32 (целое 32 бита), функция тоже возвращает результат типа i32, но записывать это надо через стрелку, а не через двоеточие. Для примера функция просто возвращает значение аргумента x как результат. Заметьте, что отсутствует слово return. Вы можете написать:
return x;
А можете написать просто:
x
Без точки с запятой в конце, это важно.
Всё это пока хоть и "не как у всех", но ничего такого ужасного.
Владение
Напишем структуру Data и изменим типы для функции, чтобы работала со структурой и возвращала её же:
Теперь инициализируем переменную foo как структуру типа Data и передадим её в функцию test():
После этого попробуем распечатать значение 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 они уже "прошитые" и поэтому добавляются через директивы компилятора:
Теперь при передаче в функцию значения переменная foo не теряет владения своими данными. Они не переместились, а скопировались. Но в ассемблерном коде при этом ничего не меняется, потому что оно и раньше точно так же копировалось через параметры функции. Как я и говорил, ограничение чисто концептуальное.
Зачем это надо? Данные, которыми владеет переменная, прекращают свое существование вместе с переменной. А когда прекращает существование переменная, мы уже знаем: когда заканчивается блок, в котором она объявлена. Таким образом, когда у данных только один владелец, всё становится однозначно. Ушёл владелец – ушли и данные.
Ну и для смены владельца данные не обязательно передавать куда-то, можно просто присвоить другой переменной:
Этот код не скомпилируется по целым двум причинам, так как foo потеряла владение, а переменная bar, которая его получила, умерла.
Заимствования
Вместо передачи владения данные можно позаимствовать (borrow). В терминах C это вроде как получить указатель, но не совсем.
Заимствования могут быть немутабельные (& foo) и мутабельные (& mut foo).
Немутабельных может быть сколько угодно:
Переменная, владеющая немутабельной ссылкой на чужие данные, может ими пользоваться, но не может их менять. Но и сам владелец, в данном случае foo, не может менять собственные данные, если кто-то владеет ссылкой на них. Такой код не скомпилируется:
А такой – да, потому что переменные bar и baz успевают умереть вместе со ссылками.
Мутабельная ссылка позволяет модифицировать данные. Но ею может владеть только кто-то один. И естественно, на это время хозяин не может вмешиваться.
Это как бы временная передача владения, до смерти того, кто взял в долг.
Можно передать мутабельную ссылку в функцию:
Функция успешно меняет значение по ссылке, и затем владение мутабельной ссылкой заканчивается. Выглядит как указатель в C, но...
Время жизни
Создадим новый тип данных, теперь с мутабельной ссылкой внутри:
Создадим переменную foo такого типа и проинициализируем ссылку, взяв её у переменной bar.
Программа не компилируется!
Проблема в объявлении структуры Data. Она содержит ссылку на что-то, что будет известно лишь потом. Но Rust не хочет, чтобы это было известно потом. У переменной типа Data будет некое время жизни от момента создания до момента смерти. И у переменной, от которой Data возьмёт ссылку, тоже будет своё время жизни.
Так вот Rust хочет, чтобы мы дали гарантии, что переменная по ссылке в Data будет жить не меньше, чем живёт сама Data.
Для этого мы с помощью дженериков и уродливого синтаксиса должны пометить время жизни для каждого участника:
В данном случае буква "a" это просто условное название времени жизни, и означает это всё следующее: если переменная с типом Data живёт время жизни "a", то ссылка внутри неё должна жить не меньше чем то же самое "a".
Вернёмся к примеру:
Здесь переменная foo имеет время жизни внутри своего блока, которое становится тем самым "a". Переменная bar, от которой заимствуется ссылка, находится в том же блоке, следовательно её время жизни не меньше. Теперь программа скомпилируется.
Если же напишем так:
То программа не скомпилируется, так как время жизни baz меньше, чем у foo.
Прочее
Вот, прошёлся буквально крупными мазками и по самым верхам, и кажется даже всё довольно просто. Но если углубляться и реально писать программу с множеством взаимосвязанных структур, вылезет куча проблем, а точнее говоря, не учтённых ранее обстоятельств, которые в других языках просто не замечались.
По оставшемуся:
- ООП в Rust это методы, приписываемые к структурам. Плюс трейты, выполняющие роль множественного наследования и/или интерфейсов
- Абсурдно сложные дженерики
- Вы не можете вернуть NULL, всегда возвращается составной тип Some/None, Ok/Error и т.п.
- Enum-типы, где можно перечислять целые структуры
- Оператор match, работающий как switch, только по типам (что-то вроде рефлексии)
- И многое другое
Заключение
- Язык совсем не для новичков, ну вот просто совсем
- На примерах всё хорошо, в реальных задачах – доводит до отчаяния, вгоняет в ступор
- Есть, конечно, в этом какая-то красота, но жертв она требует серьёзных
- Вероятно, это мостик к изучению функциональных языков