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

Плюсы:

  1. Идеальная согласованность, так как все всегда копируется.

  2. Мутабельность явно указана в сигнатуре функции через 'const'

Недостатки:

  1. Нет семантики перемещения, поэтому компилятор не может проверить наличие тривиальных утечек памяти или двойных высвобождений.

  2. Функции, получившие константное значение, могут привести его обратно к неконстантному.

Мои наблюдения:

  1. 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");

Плюсы:

  1. Передача примитивов по ссылке явно указана в сигнатуре функции (ref).

Недостатки:

  1. Передача примитивов по ссылке также является явной для вызывающей стороны. Изменение сигнатуры функции подразумевает изменения во всех местах вызова.

  2. Сложные типы могут передаваться только по ссылке (в C# они даже называются “ссылочными типами”).

  3. Нет семантики заимствования без возможности изменения.

  4. Нет семантики перемещения.

Мои наблюдения:

  1. При наличии сборщика мусора, в таких языках, как 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);

Плюсы:

  1. Поддержка всех основных семантических случаев перемещения/копирования/заимствования.

  2. Заимствование явно указано в сигнатуре функции.

Недостатки:

  1. Что будет использоваться по умолчанию - перемещение или копирование, определяется для каждого типа отдельно. Это означает, что сигнатура функции не может сказать вам, что будет использовано для данного параметра.

  2. Если перемещение/копирование нежелательно в конкретной ситуации, то есть два способа отключить это поведение по умолчанию в зависимости от того, в каком направлении вам нужно двигаться (перемещение ->копирование или копирование->перемещение).

  3. Примитивные типы нельзя перемещать. Это, вероятно, не имеет такого большого значения, но я считаю это непоследовательным.

Мои наблюдения:

  1. 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);

Так копирование является явным и не связано с вызовом функции.

Чего же я на самом деле хочу от языка программирования?

Мои самые большие претензии к языкам программирования на данный момент таковы:

  1. Отсутствие последовательности.

  2. Копирование связано с вызовом функций.

Очевидным решением здесь является явная семантика перемещения/заимствования с последовательными умолчательным и явным операторами копирования.

  1. Семантически, поведением по умолчанию всегда является иммутабельное заимствование, независимо от типа

  2. Явно аннотируйте параметры функции для всех остальных случаев. Аннотации иммутабельности и заимствования могут быть необязательными.

  3. Явные аннотации не требуются в местах вызова.

  4. Мутабельность неявно преобразуется в иммутабельность. Иммутабельность никогда не может быть преобразована в мутабельность.

  5. Копирование всегда явное.

Язык

Перемещение

Копирование

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

По умолчанию 
(для примитивных типов)

По умолчанию
(для сложных типов)

Иммутабельные параметры

Гипотетический

Да

Да

Да

Иммутабельное заимствование

Иммутабельное заимствование

Да (отключаются посредством '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 для сборки и работы с кодом. Записаться на урок можно по ссылке.

Комментарии (17)


  1. vic-35
    31.01.2023 14:16
    +1

    Пожалуйста исправьте тексты на С. Вы забыли из исходника перенести признак указателя.

    int example1(int value, char *message) {
    ......
    char* msg = malloc(100);


    1. MaxRokatansky Автор
      31.01.2023 16:08

      Действительно, при копировании пропали. Спасибо, исправили)


      1. domix32
        01.02.2023 11:45
        +1

        Всё ещё нет


  1. orekh
    31.01.2023 15:03

    Как при этом будут выглядеть простые арифметические выражения?
    let s = (copy x * copy x + copy y * copy y).sqrt();?


    1. DarkEld3r
      01.02.2023 14:14

      Я что-то упускаю или тут не нужно копировать? Если операторы заимствуют значения, то по идее всё будет работать без изменений.


  1. fishHook
    31.01.2023 15:12

    В чем разница между копированием и перемещением?

    Когда мы что-то перемещаем, то в исходном месте это что-то исчезает, правильно? А когда копируем, то нет. По-моему, разница очевидна и в запутанных пояснениях не нуждается. В расте вы не сможете использовать перемещенную переменную, потому что её больше там нет

    Интересно то, что из всех этих языков последователен в плане семантики
    только C. При вызове функции все значения всегда копируются. Вот так
    просто.


    А вы уверены, что в расте при перемещении не происходит копирование пямяти? Я имею в виду, что на стек что-то надо же положить, вот это "что-то" это не копия перемещаемого объекта? А что тогда?



    1. amishaa
      31.01.2023 15:22

      Компилятор Раста довольно часто заменяет копирование на перемещение, если "скопированный" объект больше не используется.


      1. godzie
        31.01.2023 18:31

        Может быть вы имели ввиду клонирование а не копирование?

        Потому как перемещение в расте реализовано через memcpy (другими словами через копирование)


    1. DarkEld3r
      01.02.2023 14:12

      А вы уверены, что в расте при перемещении не происходит копирование пямяти?

      Всё верно, копирование происходит, но тут важна разница между условным "копированием указателя" и глубоким копированием. На примере вектора: мы или перемещаем его (копируя только указатель, размер и capacity) или копируем все данные.


    1. mkpankov
      03.02.2023 10:38

      Когда мы что-то перемещаем, то в исходном месте это что-то исчезает, правильно?

      Нет, не правильно, конечно. Вы думаете есть какой-то способ "переместить" байты в памяти? :)

      Разница именно в интерпретации копирования. "Копирование" - это копирование после которого обеими копиями можно пользоваться как эквивалентными. "Перемещение" - это копирование после которого исходным объектом пользоваться нельзя (при этом занимаемая им память осталась на месте но теперь считается свободной).


  1. DarkEld3r
    01.02.2023 14:11

    Понимаю, что дискутировать с автором нет смысла так как это перевод, но явноe перемещение будет слишком зашумлять код, хотя и добавляет "консистентности". Хотя когда-то тоже задавался схожим вопросом относительно "единообразия" передачи по значению или по константной ссылке. Примитивы принято передавать по значению независимо от семантики функции, но порой возникают граничные случаи когда задумываешься достаточно ли большой тип, что уже пора передавать по ссылке или нет. Предложенный гипотетически язык это в принципе решает.


  1. Gryphon88
    01.02.2023 14:24

    Можете пояснить, а зачем нужна семантика перемещения? Лично я в большинстве случаев предполагаю, что данные «принадлежат» вызывающей функции, т.е. при передаче сложного типа (или бестипового объекта aka область памяти) удаление и/или очистка ресурса должно проводиться вне функции. Если мы передали объект в функцию, а он там умер, или указатель инвалидизировался, это side effect и так писать не стоит.


    1. DarkEld3r
      01.02.2023 16:17

      Например, есть у нас вектор данных, которые хочется обработать в другом потоке. Что делать без перемещения? Передавать указатель, потом как-то получать ответ от потока, что данные можно прибивать?.. Неудобно.


      Ну или более простой пример с сеттерами: хотим заменить значение поля. Можно передать по ссылке и скопировать, а можно переместить (передать владение). Если снаружи объект больше не нужен, то экономим на копировании.


      1. Gryphon88
        01.02.2023 16:29

        Например, есть у нас вектор данных, которые хочется обработать в другом потоке. Что делать без перемещения?
        Я в таких случаях или передаю передаю копию, а оригинал прибиваю руками, или да, указатель, все равно его надо маркировать как занятый, на случай если делим массив объектов между потоками. Я понимаю, что по сути вкусовщина и сахар, просто непривычно.


        1. DarkEld3r
          01.02.2023 20:28

          Я бы не сказал, что это просто вкусовщина: во многих случаях перемещение выгоднее по производительности так как мы избегаем лишнего копирования, ну или просто удобнее.


          1. Gryphon88
            02.02.2023 12:21

            А как это в Расте фактически реализовано? memmove на адрес на стеке?


            1. DarkEld3r
              03.02.2023 01:47

              Tам memcpy, но суть в том, что компилятор не даст использовать "перемещённые" данные. Ну и я ещё раз подчеркну, что перемещение важно для данных вроде строк или векторов, которые состоят из указателя на данные и небольшого числа служебных полей. Для таких типов побитовое копирование дёшево, а глубокое — дорого.