Существует три основных способа передачи данных в функции: перемещение (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 для сборки и работы с кодом. Записаться на урок можно по ссылке.