Существует три основных способа передачи данных в функции: перемещение (move), копирование (copy) и заимствование (borrow, иными словами, передача по ссылке). Поскольку изменяемость (мутабельность) неразрывно связана с передачей данных (например, эта функция может заимствовать мои данные, но только если она обещает смотреть на них и ничего более), в итоге мы получаем шесть различных комбинаций.
Перемещение, копирование, заимствование, мутабельность и иммутабельность
Каждый язык программирования имеет свой уровень поддержки и подход к этим семантикам:
Язык |
Перемещение |
Копирование |
Заимствование |
По умолчанию |
По умолчанию |
Иммутабельные параметры |
С |
Нет |
Да |
Нет* |
Копирование |
Нет |
Да (указывается посредством ‘const’) |
С# |
Нет |
Да |
Да |
Копирование |
Мутабельное заимствование |
Нет |
Java |
Нет |
Да |
Да |
Копирование |
Мутабельное заимствование |
Да (указывается посредством ‘final’) |
Rust |
Да |
Да |
Да |
Копирование |
Перемещение/Копирование** |
Да (отключаются посредством ‘mut’) |
* Технически C поддерживает заимствование через указатели. Однако фактические данные (т.е. адрес, хранящийся в указателе) всегда копируются. Таким образом, можно утверждать, что C поддерживает косвенное заимствование, а не прямое заимствование, как, например, в Rust.
** В Rust по умолчанию используется перемещение, но типы, реализующие трейт Copy, по умолчанию копируются.
Кто знал, что все окажется так запутанно? В C все типы являются примитивными. Когда вы хотите передать “ссылку”, вы передаете адрес ячейки памяти через указатель. Этот указатель в действительности представляет собой просто целочисленное значение, которое копируется так же, как и все остальное. В большинстве языков со сборкой мусора, таких как C# или Java, сложные типы передаются по изменяемой ссылке. Rust же немного выделяется на фоне остальных языков, поддерживая все шесть семантик и по умолчанию используя для сложных типов семантику перемещения, а не заимствования.
Непоследовательность
Интересно то, что из всех этих языков последователен в плане семантики только C. При вызове функции все значения всегда копируются. Вот так просто. Во всех других языках замена одного типа параметра на другой в какой-нибудь прекрасно работающей до этого функции не гарантирует, что она продолжит работать должным образом. Давайте взглянем на пару примеров, приведенных ниже.
C
int example1(int value, char message) { // value копируется // message копируется, память, на которую указывает message, не копируется } char* msg = malloc(100); example1(1, msg); // example1 неявно обязан никуда не передавать msg (заимствование) free(msg);
int example2(int value, char message) { // value копируется // message копируется, память, на которую указывает message, не копируется free(msg); } char* msg = malloc(100); example2(1, msg); // example2 неявно обязан высвобождать msg (перемещение)
int example3(int value, const char message) { // value копируется // message копируется, память, на которую указывает message, не копируется // message является неизменяемым, однако мы можем убрать неизменяемость с помощью приведения типа (каста)... char mut_message = (char )message; } char* msg = malloc(100); example3(1, msg); free(msg);
Плюсы:
-
Идеальная согласованность, так как все всегда копируется.
-
Мутабельность явно указана в сигнатуре функции через ‘const’
Недостатки:
-
Нет семантики перемещения, поэтому компилятор не может проверить наличие тривиальных утечек памяти или двойных высвобождений.
-
Функции, получившие константное значение, могут привести его обратно к неконстантному.
Мои наблюдения:
-
C поддерживает только семантику копирования, но оптимизирующие компиляторы преобразуют в перемещения все, что возможно. В этих случаях оптимизированный код обычно эквивалентен передаче указателя.
C#
int example1(int value, string message) { // value копируется // message заимствуется с возможностью изменения (передается по ссылке) } example1(1, "hello world");
int example2(ref int value, string message) { // value заимствуется с возможностью изменения (передается по ссылке) // message заимствуется с возможностью изменения (передается по ссылке) } example2(ref int_var, "hello world");
Плюсы:
-
Передача примитивов по ссылке явно указана в сигнатуре функции (ref).
Недостатки:
-
Передача примитивов по ссылке также является явной для вызывающей стороны. Изменение сигнатуры функции подразумевает изменения во всех местах вызова.
-
Сложные типы могут передаваться только по ссылке (в C# они даже называются “ссылочными типами”).
-
Нет семантики заимствования без возможности изменения.
-
Нет семантики перемещения.
Мои наблюдения:
-
При наличии сборщика мусора, в таких языках, как C# или Java, вероятно, нет особого смысла для семантики перемещения, поскольку сборщик мусора в конечном итоге позаботится обо всей памяти, выделенной в куче.
Rust
fn example1(value: i32, message: String) -> i32 { // value копируется // message перемещается } example1(1, "hello world".to_string());
fn example2(value: i32, message: SomeTypeThatImplementsCopy) -> i32 { // value копируется // message копируется } example2(1, some_copy_var);
fn example3(value: &i32, message: &mut String) -> i32 { // value заимствуется без возможности изменения // message заимствуется с возможностью изменения } let val = 1; let mut msg = "hello world".to_string(); example3(&val, &mut msg);
Плюсы:
-
Поддержка всех основных семантических случаев перемещения/копирования/заимствования.
-
Заимствование явно указано в сигнатуре функции.
Недостатки:
-
Что будет использоваться по умолчанию — перемещение или копирование, определяется для каждого типа отдельно. Это означает, что сигнатура функции не может сказать вам, что будет использовано для данного параметра.
-
Если перемещение/копирование нежелательно в конкретной ситуации, то есть два способа отключить это поведение по умолчанию в зависимости от того, в каком направлении вам нужно двигаться (перемещение ->копирование или копирование->перемещение).
-
Примитивные типы нельзя перемещать. Это, вероятно, не имеет такого большого значения, но я считаю это непоследовательным.
Мои наблюдения:
-
Rust стремится превзойти C, поддерживая семантику перемещения, но неявное перемещение/копирование вносит некоторый беспорядок. Со стороны реализации функции не имеет значения, что в итоге будет задействовано, потому что данные в любом случае принадлежат функции. Но, с точки зрения вызывающей стороны, вы всегда должны помнить, какие типы реализуют трейт Copy. Это также может привести к неочевидным проблемам:
fn foo<T>(value: T) { // ... } // это работает let c: char = 'c'; foo(c); // здесь c был скопирован foo(c); // здесь c был скопирован // а это нет let s: String = "c".to_string(); foo(s); // здесь s была перемещена foo(s); // s больше не находится в этой области видимости; ошибка компиляции
Как человек, которого раздражает такая непоследовательность в языках программирования, я задался вопросом, есть ли лучший способ.
Копирование — это замаскированное перемещение
В чем разница между копированием и перемещением? Если вы немного поразмыслите над этим, вы сразу поймете, что копирование — это просто дублирование данных с последующим перемещением. Единственная разница, с точки зрения вызывающей стороны, заключается в том, перемещаете ли вы исходные данные или их копию. С точки зрения функции разницы нет; данные получены в любом случае. Возникает вопрос, почему вообще копирование связано с сигнатурами функций? Не лучше ли иметь какую-нибудь явную поддержку копирования на уровне языка, которая никак не связана с функциями или их сигнатурами? Rust, например, стоило очень многих хлопот сохранить явную аллокацию данных, но, в конечном итоге, главный индикатор, будет ли что-либо копироваться или перемещаться, сводится к тому, как это называется. Почему бы не сделать что-то простое и понятное как, например, это?
// отправляет сложный тип в другой поток или добавляет в очередь на выполнение и т.д. fn send(item: move ComplexType) { ... } let original: ComplextType = ComplexType::new(); // создаем явную копию элемента let clone = copy original; // отправляем копию оригинала send (clone); // - или - send(copy original); // отправляем исходный элемент send(original);
Так копирование является явным и не связано с вызовом функции.
Чего же я на самом деле хочу от языка программирования?
Мои самые большие претензии к языкам программирования на данный момент таковы:
-
Отсутствие последовательности.
-
Копирование связано с вызовом функций.
Очевидным решением здесь является явная семантика перемещения/заимствования с последовательными умолчательным и явным операторами копирования.
-
Семантически, поведением по умолчанию всегда является иммутабельное заимствование, независимо от типа
-
Явно аннотируйте параметры функции для всех остальных случаев. Аннотации иммутабельности и заимствования могут быть необязательными.
-
Явные аннотации не требуются в местах вызова.
-
Мутабельность неявно преобразуется в иммутабельность. Иммутабельность никогда не может быть преобразована в мутабельность.
-
Копирование всегда явное.
Язык |
Перемещение |
Копирование |
Заимствование |
По умолчанию |
По умолчанию |
Иммутабельные параметры |
Гипотетический |
Да |
Да |
Да |
Иммутабельное заимсвтвоание |
Иммутабельное заимсвтвоание |
Да (отключаются посредством ‘mut’) |
Копирование теперь выходит за рамки этой таблицы для нашего гипотетического языка, потому что теперь это отдельная языковая фича.
Заимствование
fn example1(value: i32, message: String) { // то же, что и для fn example1(value: ref i32, message: ref String) { // value и message заимствуются без возможности изменения } let mut val = 1; // val является мутабельной в этой области видимости let msg = "hello world"; // msg иммутабельное в этой области видимости example1(val, msg); // оба могут быть заимствованы без возможности изменения example1(val, msg); // многократно
fn example2(value: mut i32, message: mut String) { // то же, что и для fn example2(value: mut ref i32, message: mut ref String) { // value и message заимствуются с возможностью изменения value += 2; } let val = 1; let msg = "hello world"; example2(val, msg); // ошибка компиляции, и val, и msg иммутабельные let mut val = 1; let mut msg = "hello world"; example2(val, msg); example2(val, msg); // value равно 5
Перемещение
fn example3(value: move i32, message: move String) { // value и message перемещаются без возможности изменения } let mut val = 1; let msg = "hello world"; example3(val, msg); // val становится иммутабельной при перемещении example3(val, msg); // ошибка компиляции, val и msg перемещены в example3
fn example4(value: mut move i32, message: mut move String) { // value и message перемещаются с возможностью изменения } let mut val = 1; let msg = "hello world"; example4(val, msg); // ошибка компиляции, msg иммутабельное example4(val, msg); // ошибка компиляции, val и msg перемещены в example4
Копирование
fn example5(value: move i32, message: move String) { // value и message перемещаются без возможности изменения } let val = 1; let msg = "hello world"; example5(copy val, copy msg); example5(copy val, copy msg);
fn example6(value: mut move i32, message: mut move String) { // value и message копируются с возможностью изменения value += 2; } let mut val = 1; let msg = "hello world"; // мутабельность определяется псевдонимом (в данном случае message) // значит, тут все в порядке, хоть msg и иммутабельно example6(copy val, copy msg); example6(copy val, copy msg); // val по-прежнему 1
Приглашаем всех желающих на открытое занятие «Сборка и запуск приложений. Туллинг Rust», которое состоится уже завтра в рамках онлайн-курса «Rust Developer. Basic». На занятии мы разберёмся, из каких этапов состоит сборка приложения, и как операционная система его запускает. Познакомимся с инструментами Rust для сборки и работы с кодом. Записаться на урок можно по ссылке.
ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/713910/
Добавить комментарий