Мастерство функций Rust: Руководство для начинающих по написанию эффективного кода

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В этом уроке мы углубимся в изучение функций в 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, то увидим, что тип переменной add — impl 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 для таких замыканий.

Источник: https://habr.com/ru/articles/887532/


Интересные статьи

Интересные статьи

Интернет-магазины и площадки электронной коммерции — это сложные системы, в работе которых могут случаться сбои и ошибки. Но в коммерческой среде простои в работе — это всегда убытки. В статье расскаж...
Добрый день! Хочу поделиться своим опытом по миграции Корпоративного портала и CRM Битрикс24 с одного физического сервера на 2 виртуальные машины.В связи с нарастающим количеством заявок программистам...
В наше время многие компании используют различные сервисы для связи с клиентами и партнерами. Один из самых популярных мессенджеров - WhatsApp, стал незаменимым инструментом коммуникации в бизнесе. Од...
King’s Bounty II, на первый взгляд визуально простая игра, но в ней сосредоточено огромное множество сложных и комплексных технических решений, продиктованных одновременным выходом на все платформы. О...
Привет! На нашей офигенной планете есть такое место — низовья Волги. Если бы не Волга, там была бы натуральная пустыня, как в Монголии. Но там есть река, и течёт она не просто так, а по д...