← Назад к списку тем

05. Функции и замыкания

Функции, методы, ассоциированные функции, замыкания Fn/FnMut/FnOnce, указатели на функции, higher-order functions.

Функции: сигнатуры и параметры

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

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

// Функция без возвращаемого значения возвращает unit-тип ()
fn log_message(msg: &str) {
    println!("[LOG] {msg}");
}

// Множественный возврат через кортеж
fn min_max(values: &[i32]) -> (i32, i32) {
    let min = *values.iter().min().unwrap();
    let max = *values.iter().max().unwrap();
    (min, max)
}

fn main() {
    let (lo, hi) = min_max(&[3, 1, 4, 1, 5]);
    println!("{} {lo} {hi}", add(2, 3));
}

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

Замыкания (closures): базовый синтаксис

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

fn main() {
    let add_one = |x: i32| x + 1;             // с явным типом параметра
    let add_two = |x| x + 2;                  // тип выведен из первого вызова
    let multiline = |x: i32, y: i32| -> i32 { // тело в {} с явным типом возврата
        let sum = x + y;
        sum * 2
    };

    println!("{} {} {}", add_one(5), add_two(5), multiline(2, 3));

    // Захват переменной окружения
    let factor = 3;
    let multiply = |x: i32| x * factor; // factor захвачена по ссылке автоматически
    println!("{}", multiply(10));
}

Важное отличие от обычных функций: у каждого замыкания — уникальный, анонимный, компилятором сгенерированный тип (даже если сигнатуры совпадают, два замыкания имеют разные типы). Поэтому замыкание нельзя указать как обычный именованный тип — только через generic-параметр с trait bound либо через dyn Fn/Box<dyn Fn>.

Fn, FnMut, FnOnce — три трейта замыкания

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

ТрейтЧто делает с окружениемАналогия вызова self
FnOnceМожет переместить (move) захваченные значения — поэтому вызывается максимум один разfn call(self)
FnMutМожет изменять захваченные значения по &mut, вызывается многократноfn call(&mut self)
FnТолько читает захваченные значения по &, вызывается многократно, потокобезопасно для чтенияfn call(&self)

Иерархия включения: каждое Fn — это также FnMut, и каждое FnMut — это также FnOnce (более строгий трейт — частный случай менее строгого). Поэтому функция, принимающая FnOnce, принимает любое замыкание, а функция, принимающая Fn, — только самые "необременительные".

fn main() {
    // Fn: только читает
    let name = String::from("Ferris");
    let greet = || println!("Hello, {name}!"); // захват &name
    greet(); greet(); // можно вызывать сколько угодно раз

    // FnMut: изменяет захваченное
    let mut count = 0;
    let mut increment = || { count += 1; count }; // захват &mut count
    println!("{} {}", increment(), increment());

    // FnOnce: перемещает захваченное значение, поэтому вызывается ровно раз
    let data = vec![1, 2, 3];
    let consume = move || {
        let total: i32 = data.into_iter().sum(); // data перемещена внутрь и потреблена
        total
    };
    println!("{}", consume());
    // consume(); // ОШИБКА: data уже потреблена предыдущим вызовом
}
🔑 move не то же самое, что FnOnce: Ключевое слово move заставляет замыкание захватывать переменные по значению (перемещая их), а не по ссылке. Это не всегда означает, что замыкание станет FnOnce — если внутри тела перемещённые данные только читаются (например, через &data) и не потребляются, замыкание всё ещё может реализовывать Fn. FnOnce определяется тем, потребляется ли значение внутри тела, а не самим фактом move.

Замыкания как параметры функций

Higher-order function — функция, принимающая или возвращающая другую функцию/замыкание. В сигнатуре параметра нужно выбрать наименее ограничивающий трейт, который реально требуется телу функции — это даёт вызывающему коду максимум гибкости.

fn apply_twice<F>(f: F, x: i32) -> i32
where
    F: Fn(i32) -> i32,
{
    f(f(x))
}

fn for_each_mut<F>(items: &mut Vec<i32>, mut f: F)
where
    F: FnMut(&mut i32),
{
    for item in items.iter_mut() {
        f(item);
    }
}

fn consume_all<F>(f: F) -> String
where
    F: FnOnce() -> String,
{
    f()
}

fn main() {
    println!("{}", apply_twice(|x| x * 2, 5)); // 20

    let mut nums = vec![1, 2, 3];
    for_each_mut(&mut nums, |x| *x *= 10);
    println!("{nums:?}"); // [10, 20, 30]

    let owned = String::from("consumed");
    println!("{}", consume_all(move || owned));
}

Замыкания как возвращаемое значение: impl Trait и Box<dyn Fn>

Поскольку каждое замыкание — тип уникального размера, известного только компилятору, вернуть его "как есть" из функции нельзя без указания конкретного механизма — статического (impl Trait) или динамического (Box<dyn Fn>) полиморфизма.

// impl Trait — статическая диспетчеризация, конкретный тип известен на этапе компиляции,
// компилятор мономорфизирует вызов — нулевой рантайм-оверхед, но нельзя вернуть разные
// типы замыкания из разных веток if/match
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
    move |y| x + y
}

// Box<dyn Fn> — динамическая диспетчеризация через vtable, аллокация на куче,
// зато можно вернуть разные конкретные замыкания из разных веток
fn make_operation(op: &str) -> Box<dyn Fn(i32, i32) -> i32> {
    match op {
        "add" => Box::new(|a, b| a + b),
        "mul" => Box::new(|a, b| a * b),
        _ => Box::new(|_, _| 0),
    }
}

fn main() {
    let add5 = make_adder(5);
    println!("{}", add5(10)); // 15

    let op = make_operation("mul");
    println!("{}", op(3, 4)); // 12
}
impl TraitBox<dyn Fn>
ДиспетчеризацияСтатическая (мономорфизация)Динамическая (vtable)
АллокацияНетКуча, под Box
Единый тип возврата из разных ветокНе поддерживаетсяПоддерживается
Производительность вызоваИнлайнится, максимально быстроКосвенный вызов через указатель на функцию

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

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

fn square(x: i32) -> i32 { x * x }
fn cube(x: i32) -> i32 { x * x * x }

fn apply(f: fn(i32) -> i32, x: i32) -> i32 {
    f(x)
}

fn main() {
    // fn-указатель — обычный указатель размером с машинное слово, не содержит захвата
    let ops: Vec<fn(i32) -> i32> = vec![square, cube];
    for op in &ops {
        println!("{}", op(3));
    }

    println!("{}", apply(square, 5)); // обычная функция приводится к fn-указателю
}

fn — единственный тип, размер и представление которого в памяти всегда одинаковы (обычный указатель), что делает его совместимым с C ABI и полезным для FFI. Замыкание с захватом (Fn/FnMut/FnOnce), напротив, — уникальная безымянная структура, потенциально хранящая захваченные переменные, и к fn не приводится.

Higher-order functions в связке с итераторами

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

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6];

    let result: Vec<i32> = numbers
        .iter()
        .filter(|&&n| n % 2 == 0)   // оставить только чётные
        .map(|&n| n * n)             // возвести в квадрат
        .collect();

    let sum: i32 = numbers.iter().sum();
    let total_after_fold = numbers.iter().fold(0, |acc, &n| acc + n * 2);

    println!("{result:?} sum={sum} fold={total_after_fold}");
}
✅ Итераторы — не "медленнее" циклов: Благодаря мономорфизации и агрессивному инлайнингу компилятор LLVM обычно компилирует цепочки .filter().map().collect() в код, идентичный ручному циклу — так называемые "zero-cost abstractions". Предпочитайте итераторный стиль ради читаемости, не опасаясь за производительность.