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

Данная статья была переведена в рамках тренажера по языку программирования Rust

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

Функции

fn say_hello() {
    println!("Hello, world!");
}

fn main() {
    say_hello();
}

// Hello, world!

Функция в Rust определяется с помощью ключевого слова fn, за которым следует её имя. Как и в случае с переменными, имя функции должно соответствовать соглашению snake_case, то есть состоять из слов, разделённых символом подчеркивания _. Например, в предыдущем примере функция say_hello названа согласно этому стилю.

fn say_hello(name: &str) {
    println!("Hello, {}!", name);
}

fn main() {
    say_hello("John");
}

// Hello, John!

Если мы хотим передавать данные в функцию, мы можем сделать это с помощью аргументов функции. Они указываются в круглых скобках (), вместе с их типами данных. Если у функции несколько аргументов, они должны быть разделены запятыми. В приведённом выше примере функция say_hello принимает только один аргумент типа &str.

fn say_hello(name: &str) -> String {
    return format!("Hello, {}!", name);
}

fn main() {
    let message: String = say_hello("John");
    println!("{}", message);
}

// Hello, John!

Возвращаемые значения

Функция также может возвращать значение. Если функция должна возвращать результат, это необходимо указать с помощью ->, за которым следует тип возвращаемого значения. В примере выше функция say_hello возвращает значение типа String.

Для возврата значения используется оператор return, за которым следует само значение. В идеале оператор return должен быть последним выражением в теле функции, однако он может встречаться и в других местах кода.

fn say_hello(name: &str) -> String {
    if name == "unknown" {
        return format!("Hello, world!");
    }

    return format!("Hello, {}!", name);
}

fn main() {
    println!("[1] {}", say_hello("John"));
    println!("[2] {}", say_hello("unknown"));
}

// [1] Hello, John!
// [2] Hello, world!

В приведённом выше примере в теле функции say_hello используется оператор if. Если переданный аргумент name имеет значение "unknown", выполняется код внутри блока if, содержащий оператор return. Когда Rust встречает return, он немедленно завершает выполнение функции и возвращает указанное значение, игнорируя любой код, находящийся ниже него.

fn say_hello(name: &str) -> String {
    println!("Executing say_hello()");

    format!("Hello, {}!", name)
}

fn main() {
    println!("{}", say_hello("John"));
}

// Executing say_hello()
// Return: Hello, John!

Однако, чтобы вернуть значение из функции, оператор return не всегда обязателен. В уроке о потоке управления мы рассматривали, что if/else может быть выражением, и его значение вычисляется. Тот же принцип применяется к функциям. В Rust, если последняя строка в теле функции является выражением (и не содержит точку с запятой ;), это значение неявно возвращается функцией. В приведённом выше примере format!("Hello, {}!", name) является последней строкой в теле функции и не содержит;, поэтому оно автоматически возвращается.

fn say_hello(name: &str) -> String {
    if name == "unknown" {
        format!("Hello, world!")
    } else {
        format!("Hello, {}!", name)
    }
}

fn main() {
    println!("[1] {}", say_hello("John"));
    println!("[2] {}", say_hello("unknown"));
}

// [1] Hello, John!
// [2] Hello, world!

В примере выше функция say_hello неявно возвращает одно из значений format!(), в зависимости от условия if. Но как это работает? Мы выяснили, что неявный возврат происходит только в том случае, если последняя строка в теле функции является выражением. Однако ни одно из выражений format!() не стоит на последней строке. Чтобы лучше понять это, давайте рассмотрим следующий код.

fn say_hello(name: &str) -> String {
    return if name == "unknown" {
        format!("Hello, world!")
    } else {
        format!("Hello, {}!", name)
    };
}

fn main() {
    println!("[1] {}", say_hello("John"));
    println!("[2] {}", say_hello("unknown"));
}

// [1] Hello, John!
// [2] Hello, world!

Этот пример идентичен предыдущему, за исключением одного изменения в функции say_hello. Здесь мы явно возвращаем выражение if/else. Таким образом, становится ясно, что последняя строка кода в предыдущей функции say_hello действительно была выражением, но оно было распределено на несколько строк. Приносим извинения за некорректное использование терминологии.

Функция, которая не имеет явного или неявного механизма возврата, по умолчанию возвращает () (тип unit).

fn say_hello(name: &str) {
    println!("Hello, {}!", name);
}

fn main() {
    let result = say_hello("John");
    println!("result: {:?}", result);
}

// Hello, John!
// result: ()

В приведённом выше примере say_hello не возвращает никакого значения, но если мы попробуем сохранить его результат в переменной и вывести, то увидим (). Это эквивалентно объявлению функции как fn say_hello(name: &str) -> (). Основная функция main, которую мы хорошо знаем, также не принимает аргументов и возвращает ().

fn count_until(max: u8) -> String {
    let mut counter: u8 = 0;

    loop {
        if counter == max {
            return format!("Stopped at {}.", max);
        }

        println!("Current counter is: {}", counter);
        counter += 1;
    }
}

fn main() {
    let result: String = count_until(3);

    println!("result: {}", result);
}
$ cargo run
Current counter is: 0
Current counter is: 1
Current counter is: 2
result: Stopped at 3.

Как мы уже узнали, оператор return не только возвращает значение, но и может завершить выполнение функции досрочно. Если внутри функции есть цикл (например, for, while или loop), оператор break завершит только выполнение цикла, но не функции. Однако оператор return может сделать и то, и другое.

В приведённом выше примере, когда счётчик counter достигает максимального значения max, выполняется return ..., который завершает как выполнение цикла, так и всей функции, возвращая указанное значение.

Также можно использовать return; без указания значения, чтобы прервать выполнение функции досрочно, не возвращая никакого результата (фактически, функция возвращает ()).

Отсутствие необязательных параметров (аргументов)

Динамические языки программирования, такие как JavaScript или Python, позволяют объявлять функции с необязательными параметрами или переменным числом аргументов. Это означает, что аргумент можно передавать или не передавать, и функция всё равно будет работать. Например, в TypeScript можно объявить функцию sayHello, где параметр name является необязательным. Также можно задать параметру значение по умолчанию function sayHello(name: string = "world")

function sayHello(name?: string): string {
  if (!name) {
    return "Hello, world!";
  }

  return `Hello, ${name}`;
}

console.log(sayHello("John")); // argument passed
console.log(sayHello()); // no argument passed

// "Hello, John"
// "Hello, world!"

Данная статья была переведена в рамках тренажера по языку программирования Rust

Однако синтаксис функций в Rust в этом случае довольно строгий. В Rust функция должна объявлять все параметры, и при вызове функции все аргументы должны быть явно переданы. Понятие аргумента, который не передан, в Rust не существует. Однако мы можем использовать перечисление Option, чтобы имитировать поведение пустого аргумента, при этом оставаясь в рамках правил Rust.

fn say_hello(name: Option<&str>) -> String {
    match name {
        Some(text) => format!("Hello, {}!", text),
        None => format!("Hello, world!"),
    }
}

fn main() {
    println!("[1] {}", say_hello(Some("John")));
    println!("[2] {}", say_hello(None));
}

// [1] Hello, John!
// [2] Hello, world!

В приведённом выше примере вместо того, чтобы say_hello принимала &str, она теперь принимает Option<&str>. Мы подробнее разберём перечисление Option в следующем уроке. Перечисление Option имеет два варианта: Some и None. Вариант Some может хранить значение. В случае типа Option<&str> вариант получает тип Some(&str), что означает, что он хранит значение типа &str. Используя оператор match, мы можем определить, является ли переданный аргумент Some или None. Благодаря сопоставлению с образцом внутри ветки match мы можем извлечь значение &str из Some(&str) и выполнить соответствующие действия.

Здесь say_hello неявно возвращает String, потому что match используется как выражение, и поскольку это единственное выражение в функции, оно возвращается неявно.

Рекурсивные функции

Рекурсия функций поддерживается в Rust, как и во многих других языках, поскольку вызовы функций хранятся в стеке. Когда вызывается функция, создаётся стековый кадр (stack frame), который содержит аргументы функции, локальные переменные, временные данные и адрес возврата (указывающий, куда программа должна вернуться после завершения функции).

Рекурсивная функция вызывает саму себя многократно, создавая дополнительные стековые кадры. Однако должна существовать ситуация, при которой функция больше не вызывает саму себя, а вместо этого возвращает значение. В этот момент начинается сворачивание стека (stack unwinding). Это означает, что каждый вызов функции завершает выполнение, а соответствующий стековый кадр удаляется из стека.

fn factorial(n: u32) -> u32 {
    if n == 0 {
        return 1;
    }

    return n * factorial(n - 1);
}

fn main() {
    let result = factorial(4);

    println!("4! = {}", result);
}

// 4! = 24

В приведённом выше примере у нас есть типичная рекурсивная функция вычисления факториала, которая принимает аргумент n типа u32, факториал которого необходимо вычислить и вернуть.

Факториал числа N, обозначаемый как N!, равен N * (N-1)!. Например, для 4!:

  • 4! = 4 * 3!

  • 3! = 3 * 2!

  • 2! = 2 * 1!

  • 1! = 1 * 0!

  • 0! = 1

Поскольку 0! равно 1, дальнейшие вычисления не требуются. Это и есть базовый случай, который останавливает рекурсию.

stack building --->
let result = factorial(4)
                * factorial(3)
                    * factorial(2)
                        * factorial(1)
                            * factorial(0);
                                            <--- stack unwinding

Как описано выше, если мы вызываем factorial(4), то функция сначала проверяет, равно ли n нулю. Поскольку n == 0 ложно, выполняется return 4 * factorial(3);. Однако этот вызов пока не вернёт значение, так как factorial(3) создаёт новый стековый кадр и должно выполниться в первую очередь. Этот процесс продолжается до factorial(0), которое является базовым случаем.

На этом этапе новый стековый кадр не создаётся, и функция сразу возвращает 1, поскольку условие if n == 0 выполняется, и вызывается return 1. Затем начинается сворачивание стека (stack unwinding), что означает, что предыдущие вызовы функции начинают возвращать результаты один за другим.

Указатели на функции

Код функции хранится в скомпилированном бинарном файле и ссылается на него через имя функции. Например, say_hello — это имя функции, которое указывает на её реализацию, находящуюся в бинарном файле.

Указатель на функцию — это указатель, который указывает на этот код.

fn say_hello(name: &str) -> String {
    format!("Hello, {}!", name)
}

fn main() {
    let say_hello_ptr: fn(&str) -> String = say_hello;

    println!("{}", say_hello_ptr("John"));
}

// Hello, John

В приведённом выше примере say_hello_ptr — это указатель на функцию, который указывает на код функции say_hello.

В Rust указатели на функции являются полноценными объектами первого класса. Это означает, что они могут:

  • Храниться в переменных,

  • Передаваться в качестве аргументов в другие функции,

  • Возвращаться из функций.

Тип указателя на функцию совпадает с типом функции, но записывается в виде сигнатуры функции, без указания имени функции и её аргументов.

Например, если say_hello имеет сигнатуру fn(&str) -> String, то say_hello_ptr также будет иметь тип fn(&str) -> String.

Функции в Rust являются неизменяемыми, что означает, что их нельзя модифицировать во время выполнения. Поэтому мы не можем изменять их через указатель на функцию. В отличие от некоторых типов данных, таких как String или Vec, которые можно клонировать с помощью метода .clone(), этот концепт не применим к указателям на функции.

Однако указатель на функцию — это не совсем то же самое, что анонимная функция, с которыми вы могли быть знакомы в других языках программирования. Ближайшей аналогией в Rust являются замыкания (closures).

Константные функции (const functions)

Константная функция (или const-функция) похожа на обычную функцию, объявленную с помощью ключевого слова fn, но перед ней используется ключевое слово const.

Однако, в отличие от обычной функции, const-функция может быть вычислена во время компиляции. Если она используется в контексте, где требуется константное значение (например, при задании начального значения для const или static переменной, либо при определении длины массива), Rust-компилятор выполнит её во время компиляции и подставит полученный результат.

const fn get_squre(num: usize) -> usize {
    num * num
}

const SQUARE_OF_2: usize = get_squre(2);
static SQUARE_OF_3: usize = get_squre(3);

fn main() {
    let a: [i32; 4] = [1; get_squre(2)];

    println!("a: {:?}", a);
}

// a: [1, 1, 1, 1]

В приведённом примере функция get_square является константной (const) функцией. Поскольку значения переменных const и static должны быть известны на этапе компиляции, мы можем вызвать функцию get_square(), чтобы получить это значение, так как оно вычисляется во время компиляции. Поскольку длина массива также должна быть известна на этапе компиляции, мы можем использовать возвращаемое значение константной функции.

Обычно константные const функции даже не попадают в скомпилированный бинарный файл, так как они вычисляются на этапе компиляции и не имеют смысла во время выполнения, если только они не используются во время выполнения. Это происходит, когда они вызываются в неконстантном контексте, например, когда константная функция используется для присваивания возвращаемого значения переменной let, как в let a = get_square(2), где результат вычисляется во время выполнения.

Существует несколько ограничений для константных const функций, поскольку они вычисляются на этапе компиляции. Некоторые из наиболее распространённых ограничений:

  • Отсутствие выделения памяти на куче: Нельзя выделять память на куче внутри функции, например, для String, Vec и т. д. Можно использовать только данные фиксированного размера, основанные на стеке.

  • Отсутствие изменяемости: const-функции должны быть без состояния и не могут создавать побочные эффекты, такие как изменение переменной.

  • Отсутствие операций ввода/вывода: Нельзя выполнять операции ввода/вывода (например, чтение или запись в файл), потому что их выполнение не может быть гарантировано на этапе компиляции.

  • Отсутствие вызовов неконстантных функций: Внутри const-функции можно вызывать только другие функции, которые также помечены как const.

Оптимизация функций

#[inline(always)]
fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn main() {
    let result = add(1, 2);
    println!("{}", result);
}

Иногда компилятор встраивает код функции непосредственно в место её вызова, чтобы избежать накладных расходов на вызов функции во время выполнения. В приведённом выше примере атрибут #[inline(always)] для функции add заставит компилятор разворачивать её в месте вызова. Таким образом, во время компиляции выражение let result = add(1, 2); превратится в let result = 1 + 2;.

Однако у встраивания есть и недостатки, такие как увеличение размера бинарного файла и снижение удобства отладки. Поэтому лучше оставить выбор стратегии встраивания за компилятором Rust или использовать различные вариации макроса #[inline] в зависимости от наших потребностей.

  • Используйте #[inline] для небольших, часто вызываемых функций, где избегание накладных расходов на вызов функции может быть полезным.

  • Используйте #[inline(always)] для критически важных участков кода, где накладные расходы на вызов функции недопустимы.

  • Используйте #[inline(never)] в случаях, когда встраивание нецелесообразно, например, если оно излишне увеличивает размер бинарного файла или если вы хотите избежать встраивания для удобства отладки.

#[cold]
fn rare() {
    // --snip--
}

#[hot]
fn common() {
    // --snip--
}

Мы также можем использовать атрибуты #[cold] или #[hot], чтобы указать компилятору, как оптимизировать функции.

  • #[cold] сообщает компилятору, что эта функция используется редко, поэтому её следует держать подальше, чтобы часто используемый код выполнялся быстрее.

  • #[hot] сообщает компилятору, что эта функция используется часто, поэтому её следует держать ближе и оптимизировать для максимальной скорости выполнения.

Замыкания (Closures)

Замыкание в Rust похоже на стрелочную функцию в JavaScript или лямбда-функцию в Python. Если вы не знакомы с этим понятием, разберём его подробнее.

Обычная функция в Rust, определяемая с помощью ключевого слова fn, имеет имя, является неизменяемой и бесплодной (не хранит состояние). Однако замыкание похоже на функцию, но может захватывать данные из своей окружающей среды и является анонимным (то есть не имеет имени).

Замыкания объявляются с помощью синтаксиса || <expr>. Поскольку они анонимные, их нужно либо присваивать переменной, передавать как аргумент или возвращать как значение.

Как и указатели на функции, замыкания в Rust являются полноценными объектами первого класса (first-class citizens).

fn main() {
    let say_hello = || println!("Hello, world!");
    say_hello();
}

// Hello, world!

В приведённой выше программе переменная say_hello содержит замыкание. Это замыкание не принимает аргументов и ничего не возвращает.

Выражением <expr> в || <expr> является println!("Hello, world!"), которое возвращает ().

Мы можем вызывать say_hello так же, как обычную функцию, поскольку замыкания в Rust по сути являются функциями.

Вы также можете использовать блок {} как выражение, если у вас больше одной строки кода в замыкании, например: let say_hello = || { println!("Hello, world!"); };

fn main() {
    let say_hello = |name: &str| format!("Hello, {}!", name);
    println!("{}", say_hello("John"));
}

// Hello, John!

В приведённом примере замыкание принимает аргумент типа &str и возвращает значение типа String. Мы заменили println! на format!, который возвращает это значение String.

Но каков тип переменной say_hello? Если посмотреть в вашей IDE, вы увидите, что для неё был выведен тип impl Fn(&str) -> String.

Что такое impl Fn? Чтобы это понять, нам нужно разобраться, чем замыкания отличаются от обычных функций.

Самое важное отличие, помимо их анонимности, заключается в том, что замыкания могут захватывать переменные из своей окружающей среды. Что это означает? Давайте попробуем продемонстрировать это с помощью обычной функции.

Данная статья была переведена в рамках тренажера по языку программирования Rust

fn main() {
    let num: i32 = 3;

    // Error: can't capture dynamic environment in a fn item
    fn add(a: i32) -> i32 {
        a + num
    }

    let result = add(2);
    println!("result: {}", result);
}

В приведённом примере мы объявили функцию add, которая действительна только внутри функции main, но она пытается использовать переменную num, объявленную за пределами тела функции add.

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

Это означает, что функции, объявленные с fn, не могут захватывать переменные из своей окружающей среды.

Однако замыкания специально созданы для того, чтобы захватывать данные из окружающего контекста.

fn main() {
    let num: i32 = 3;

    let add = |a: i32| a + num;

    let result = add(2);
    println!("result: {}", result);
}

// result: 5

Теперь add — это замыкание, а не функция, и оно может захватывать переменные из своей окружающей среды. Программа выше работает без ошибок.

Если мы снова посмотрим в IDE, то увидим, что тип переменной addimpl Fn(i32) -> i32.

  • Fn — это трейт в Rust, который находится в стандартной библиотеке и является частью Prelude.

  • impl указывает, что это значение реализует трейт Fn, в данном случае это add.

  • После Fn следует сигнатура функции, аналогичная той, что мы видели у указателей на функции.

Но что именно делает Fn?

Мы не можем явно указать тип impl Trait для переменной. Если попытаться, например:

let add: impl Fn(i32) -> i32 = ...;

компилятор выдаст ошибку.

`impl Trait` is not allowed in the type of variable bindings
 `impl Trait` is only allowed in arguments and return types of functions and methods

Почему это происходит? Мы разберём это в уроке про трейты (Traits).

Пока что мы можем полагаться на механизм вывода типов Rust, и вы легко сможете увидеть тип замыкания в вашей IDE.

Три способа, которыми замыкания могут захватывать переменные из окружающей среды:

  • По ссылке (By reference): замыкание может захватывать переменные из окружающей среды по неизменяемой ссылке, что означает, что оно не забирает владение этими переменными.Когда замыкание обращается к таким переменным по имени, Rust неявно передаёт ссылку на исходную переменную.В приведённом выше примере переменная num внутри тела замыкания является просто ссылкой на num. Замыкание, которое захватывает свою среду только по ссылке, автоматически реализует трейт Fn, который требует реализации метода call. Rust автоматически реализует этот метод для таких замыканий.

  • По изменяемой ссылке (By mutable reference): замыкание также может захватывать переменные из окружающей среды по изменяемой ссылке &mut, что позволяет ему изменять эти переменные. Такие замыкания автоматически реализуют трейт FnMut, который требует реализации метода call_mut. Rust автоматически реализует этот метод для замыканий, которые изменяют свою окружающую среду.

  • По значению (By value): наконец, замыкание может полностью забрать владение переменными из своей окружающей среды. После того как переменные перемещены movedв замыкание, их нельзя повторно использовать за его пределами. Такие замыкания реализуют трейт FnOnce, который требует реализации метода call_once. Суффикс Once в FnOnce указывает на то, что такое замыкание может быть вызвано только один раз, если оно захватывает переменные по значению. Rust автоматически реализует метод call_once для таких замыканий.

    💡В этом контексте ключевое слово move играет важную роль при передаче переменных в замыкание по значению.

Fn trait

fn main() {
    let v: Vec<i32> = vec![1, 2, 3];

    let process = || {
        for i in &v {
            println!("i: {}", i);
        }
    };

    process();
    println!("v: {:?}", v);
}
$ cargo run
i: 1
i: 2
i: 3
v: [1, 2, 3]

В приведённом выше примере мы объявили переменную v, которая хранит Vector типа i32.

Замыкание process захватывает v по неизменяемой ссылке, так как в for-цикле используется &v. Следовательно, process реализует трейт Fn и получает тип impl Fn(). Поскольку process не забирает владение v, мы можем продолжать использовать v после вызова замыкания process.

FnMut trait

fn main() {
    let mut v: Vec<i32> = vec![1, 2, 3];

    let mut process = || {
        v.push(4);
    };

    process();
    println!("v: {:?}", v);
}
$ cargo run
v: [1, 2, 3, 4]

В этом примере замыкание process изменяет вектор v, добавляя в него значение с помощью метода .push(). Метод .push() у Vec принимает изменяемую ссылку &mut self на вектор. Из-за этого process захватывает v по изменяемой ссылке &mut v.

Чтобы иметь возможность изменять v, необходимо:

  • Объявить v как изменяемую переменную mut v,

  • Также пометить process как изменяемое замыкание mut process, так как изменение v меняет внутреннее состояние process.

Так как process захватывает свою среду только по изменяемой ссылке, оно реализует трейт FnMut и получает тип impl FnMut().

В этом случае v не перемещается moveв process, так как замыкание не забирает владение переменной. Благодаря этому мы можем использовать v после вызова замыкания process.

FnOnce trait

fn main() {
    let v: Vec<i32> = vec![1, 2, 3];

    let process = || {
        for i in v {
            println!("i: {}", i);
        }
    };

    process();

    // Error: borrow of moved value: `v`
    // println!("text: {:?}", v); // <-- uncomment

    // Error: use of moved value: `process`
    // process(); // <-- uncomment
}
$ cargo run
i: 1
i: 2
i: 3

В приведённом примере цикл forзабирает владение v, так как мы удалили & (используем v вместо &v). Следовательно, v перемещается move в замыкание process. Теперь, если мы попытаемся использовать v после объявления process, компилятор выдаст ошибку. Сообщение об ошибке "borrow of moved value" означает, что println! пытается заимствовать значение, которое уже было перемещено, а именно v в данном случае. Поскольку process забирает владение переменными из своей среды, оно автоматически реализует трейт FnOnce и получает тип impl FnOnce().

Макрос println! использует ссылку (заимствование) на значение, а не забирает его во владение. Хотя мы передали v в println!, макрос неявно использует &v благодаря своей реализации.

Мы также не можем вызвать замыкание process дважды, потому что вектор v уже был перемещён (consumed) циклом for. При следующем вызове process(), v больше не существует, и его нельзя передать в for-цикл.

move keyword

В приведённом примере замыкание process неявно забирает владение v, так как другого варианта нет – для корректной работы программы v должен быть передан по владению.

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

fn main() {
    let v: Vec<i32> = vec![1, 2, 3];

    let process = move || {
        for i in &v {
            println!("i: {}", i);
        }
    };

    process();
    process();

    // Error: borrow of moved value: `v`
    // println!("text: {:?}", v); // <-- uncomment
}

В этом примере замыкание process обычно не забирало бы владение v, так как мы используем &v, то есть замыкание захватывает только ссылку на v. Поэтому мы можем продолжать использовать v после замыкания. Однако, если мы используем ключевое слово move, мы заставляем замыкание взять владение переменной, которую оно захватывает – в данном случае, v. В результате, если мы попробуем использовать v после замыкания, мы получим ту же ошибку "borrow of moved value".

Но почему мы всё ещё можем вызывать process() несколько раз?

Хотя process является move-замыканием (из-за ключевого слова move), оно не потребляет v при выполнении, потому что for-цикл использует только ссылку на v (&v).

По этой причине замыкание process получает тип impl Fn(), что позволяет вызывать его неограниченное количество раз.

Даже при использовании ключевого слова move замыкания не забирают владение у типов, таких как i32, u8, f32, &str и т. д., поскольку они реализуют трейт Copy. Это позволяет их значениям копироваться, поэтому они могут использоваться в move-замыкании без полного перемещения (consumed).

Функции высшего порядка (Higher-order functions, HOFs)

Rust поддерживает функциональное программирование, предоставляя полноценную поддержку указателей на функции и замыканий. Одной из ключевых особенностей функционального программирования является использование функций высшего порядка (Higher-Order Functions, HOFs).

Функция высшего порядка – это функция, которая:

  • Принимает одну или несколько функций в качестве аргументов,

  • Возвращает функцию в качестве результата,

  • Или делает и то, и другое одновременно.

Использование функций высшего порядка позволяет:

  • Делать код более декларативным,

  • Повышать повторное использование кода за счёт композиции функций,

  • Абстрагировать сложную логику, упрощая структуру программы.

fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn subtract(a: i32, b: i32) -> i32 {
    a - b
}

fn operate_with_2(fun: fn(a: i32, b: i32) -> i32, value: i32) -> i32 {
    fun(2, value)
}

fn main() {
    let add_result = operate_with_2(add, 1);
    println!("add: {}", add_result);

    let subtract_result = operate_with_2(subtract, 1);
    println!("subtract: {}", subtract_result);
}
$ cargo run
add: 3
subtract: 1

В приведённом выше примере функция operate_with_2 принимает указатель на функцию в качестве первого аргумента с типом fn(a: i32, b: i32) -> i32 и второй аргумент типа i32. Идея заключается в том, чтобы вызвать переданную функцию с первым аргументом, равным 2, и вторым аргументом, равным переданному значению, а затем вернуть полученный результат. Мы определили функции add и subtract, чтобы передавать их в качестве аргументов-указателей на функцию. Таким образом, когда мы вызываем operate_with_2(add, 1), внутри выполняется вызов add(2, 1), и возвращается результат.

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

fn operate_with_2(fun: fn(a: i32, b: i32) -> i32, value: i32) -> i32 {
    fun(2, value)
}

fn main() {
    let add_result = operate_with_2(|a, b| a + b, 1);
    println!("add: {}", add_result);

    let subtract_result = operate_with_2(|a, b| a - b, 1);
    println!("subtract: {}", subtract_result);
}
$ cargo run
add: 3
subtract: 1

В приведённом выше примере вместо передачи указателя на функцию в operate_with_2 мы передаём замыкание. Но, как мы уже узнали, замыкание имеет тип impl Fn, impl FnMut или impl FnOnce, тогда как operate_with_2 принимает указатель на функцию типа fn. Так как же это работает?

Это работает потому, что передаваемое нами замыкание не захватывает переменные из своей окружающей среды, что позволяет Rust привести его к указателю на функцию. Rust автоматически приводит замыкания к указателям на функции (и наоборот), если замыкание не захватывает переменные из своей среды.

fn operate_with_2(fun: impl Fn(i32, i32) -> i32, value: i32) -> i32 {
    fun(2, value)
}

fn main() {
    let add_result = operate_with_2(|a, b| a + b, 1);
    println!("add: {}", add_result);

    let subtract_result = operate_with_2(|a, b| a - b, 1);
    println!("subtract: {}", subtract_result);
}
$ cargo run
add: 3
subtract: 1

Но если вы хотите принимать только замыкание, которое может захватывать свою окружающую среду, вы можете указать его тип как impl Fn(i32, i32) -> i32, как мы сделали выше. Как мы уже узнали, это позволит передавать только те замыкания, которые захватывают переменные из своей среды с использованием неизменяемой (immutable) ссылки.

fn print_vector(v: Vec<i32>) -> impl Fn() {
    move || {
        for i in &v {
            println!("i: {}", i);
        }
    }
}

fn main() {
    let v = vec![1, 2, 3];
    let printer = print_vector(v);

    printer();
    printer();
}
$ cargo run
i: 1
i: 2
i: 3
i: 1
i: 2
i: 3

В приведённом выше примере функция print_vector принимает Vector и возвращает замыкание типа impl Fn(). Когда мы передаём в неё Vector v с вызовом print_vector(v), функция получает владение v, что означает, что v больше нельзя использовать после этого.

Внутри print_vector мы возвращаем перемещаемое (movable) замыкание, так как хотим, чтобы оно получило владение v. Однако, поскольку цикл for внутри замыкания только заимствует v, владение остаётся у самого замыкания, и мы можем вызывать это замыкание сколько угодно раз. Поэтому при возврате из print_vector оно может иметь тип impl Fn().

fn print_vector(v: Vec<i32>) -> impl FnOnce() {
    move || {
        for i in v {
            println!("i: {}", i);
        }
    }
}

fn main() {
    let v = vec![1, 2, 3];
    let printer = print_vector(v);

    printer();

    // Error: use of moved value: `printer`
    // printer(); // <-- uncomment
}

Но если мы хотим, чтобы возвращаемое замыкание нельзя было вызвать более одного раза, мы можем указать тип возвращаемого значения print_vectorкак impl FnOnce(). Например, если цикл for внутри замыкания получает владение v, то после первого вызова замыкание больше нельзя будет вызвать, так как v будет потреблён при первом вызове. Поэтому, как показано в приведённой выше программе, мы изменили возвращаемый тип замыкания на impl FnOnce(), что предотвратит повторный вызов printer().

Функции высшего порядка являются ключевой частью стандартной библиотеки Rust. Например, если вам нужно преобразовать каждый элемент коллекции, такой как Vector, вы можете использовать метод .map() на итераторе, который требует замыкания для выполнения преобразования.

fn main() {
    let v: Vec<i32> = vec![1, 2, 3];

    let tv: Vec<i32> = v.iter().map(|i| i * i).collect();
    println!("tv: {tv:?}");
}

// tv: [1, 4, 9]

В приведённом выше примере мы сначала создали итератор из v, используя метод .iter() для вектора. Затем мы применили к нему метод .map(), передав замыкание, которое задаёт новое значение для каждого элемента. Наконец, метод .collect() используется для преобразования итератора обратно в вектор.

Каррирование (Currying)

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

Данная статья была переведена в рамках тренажера по языку программирования Rust

Для обычной функции с двумя аргументами, например, f(a, b) -> result, каррирование превращает её в f(a) -> f(b) -> result.

fn add(a: i32) -> impl Fn(i32) -> i32 {
    move |b: i32| a + b
}

fn main() {
    let add_five = add(5);
    let result = add_five(3);
    println!("5 + 3: {}", result);

    // simplified:
    println!("2 + 7: {}", add(5)(3));
}
$ cargo run
5 + 3: 8
2 + 7: 8

В приведённой выше программе, в отличие от обычной функции сложения, которая имеет сигнатуру fn add(a: i32, b: i32) -> i32, эта версия принимает только один аргумент типа i32 и возвращает замыкание. Это замыкание затем можно вызвать со вторым аргументом типа i32, чтобы получить итоговое значение i32.

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