$ sudo teach IT
Модуль 2 · Логика программы · Урок 2.1

Функции и выражения

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

Теория + практика~25 минутНовичок

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

Зачем нужны функции

Представьте кофемашину. Вы не объясняете ей каждый раз, как молоть зёрна и нагревать воду: нажимаете кнопку «эспрессо», и машина сама выполняет весь рецепт. Функция в программировании — такая же кнопка: вы один раз записываете рецепт, даёте ему имя, а потом нажимаете «кнопку» в любом месте программы.

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

Rust · main.rs (фрагмент, без функций)
fn main() {
    let may = [350, 1200, 99];
    let june = [800, 40, 2500];

    let may_total = may[0] + may[1] + may[2];
    let june_total = june[0] + june[1] + june[2];
    println!("{} {}", may_total, june_total);
}

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

Rust · main.rs
fn total(expenses: [u32; 3]) -> u32 {
    expenses[0] + expenses[1] + expenses[2]
}

fn main() {
    let may = [350, 1200, 99];
    let june = [800, 40, 2500];

    let may_total = total(may);
    let june_total = total(june);
    println!("{} {}", may_total, june_total);
}

Разбор по строкам

  • fn total(expenses: [u32; 3]) -> u32 { — объявление функции: fn (от function) — ключевое слово, total — имя, в скобках — что функция получает, после стрелки -> — что она возвращает
  • expenses[0] + expenses[1] + expenses[2] — тело функции; последняя строка без точки с запятой и есть результат (разберём ниже)
  • let may_total = total(may); — вызов функции: пишем имя и в скобках то, что отдаём ей на вход; результат сохраняется в переменную
  • let june_total = total(june); — тот же рецепт, другие данные: формула написана один раз, а работает для любого массива из трёх трат

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

Параметры: что функция получает

Ящик, который вы кладёте в кофемашину, — капсула. Без неё машина не знает, какой кофе готовить. То, что функция получает на входе, называется параметрами (parameters). В объявлении у каждого параметра есть имя и тип, разделённые двоеточием: expenses: [u32; 3] читается как «expenses — массив из трёх чисел u32».

Тип параметра обязателен всегда. Внутри тела Rust мог бы догадаться о типе сам, но на границе функции требует явности: так по одной строке объявления понятно, как функцией пользоваться. Имена принято писать в snake_case — строчными буквами, слова через подчёркивание: total, print_header, spent_so_far.

Параметров может быть несколько, они перечисляются через запятую. Когда вы вызываете функцию, значения, которые передаёте, называются аргументами (arguments): параметр — это «пустая ячейка» в объявлении, аргумент — то, что в неё кладут при вызове.

Rust · main.rs
fn percent(spent: u32, limit: u32) -> u32 {
    spent * 100 / limit
}

fn main() {
    println!("{}", percent(1649, 30000));
}

Разбор по строкам

  • fn percent(spent: u32, limit: u32) -> u32 — два параметра: сколько потрачено и каков лимит, оба типа u32; результат тоже u32
  • spent * 100 / limit — сначала умножаем, потом делим, как в практикуме первого модуля: иначе целочисленное деление округлит до нуля
  • percent(1649, 30000) — аргументы подставляются в параметры по порядку: 1649 попадает в spent, 30000 — в limit

Программа напечатает 5: 1649 рублей — это около пяти процентов от 30 000.

Порядок аргументов важен

Компилятор сверяет аргументы с параметрами только по порядку и типу. Вызов percent(30000, 1649) тоже скомпилируется (оба числа — u32) и напечатает 1819 вместо 5. Ошибки, которых компилятор не видит, ловите сами: перечитывайте объявление перед вызовом.

Возврат значения

Кофемашина не просто работает — она отдаёт вам чашку. Так и функция чаще всего что-то отдаёт обратно: число, текст, ответ «да или нет». Это возвращаемое значение (return value). Его тип пишется после стрелки ->.

В Rust есть приятное правило: результатом функции становится последнее выражение в теле, если после него нет точки с запятой. Ключевое слово return для этого не нужно. Сравните две записи, они делают одно и то же:

Rust · main.rs (фрагмент)
fn total(expenses: [u32; 3]) -> u32 {
    expenses[0] + expenses[1] + expenses[2]
}

fn total_with_return(expenses: [u32; 3]) -> u32 {
    return expenses[0] + expenses[1] + expenses[2];
}

Слово return завершает функцию немедленно и отдаёт указанное значение. Оно пригодится, когда понадобится выйти из функции пораньше, посреди тела, — например, после проверки условия (условия мы разберём в следующем уроке). А в конце функции принято писать короче: без return и без точки с запятой.

Одна лишняя точка с запятой ломает функцию

Допишем ; после последней строки total. Программа не соберётся: функция обещала вернуть u32, а точка с запятой превратила результат в «ничего». Компилятор сам подскажет, что сделать:

Ошибка компилятора
error[E0308]: mismatched types
 --> src/main.rs:1:33
  |
1 | fn total(expenses: [u32; 3]) -> u32 {
  |    -----                        ^^^ expected `u32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
2 |     expenses[0] + expenses[1] + expenses[2];
  |                                            - help: remove this semicolon to return this value

Прочитаем по-человечески. mismatched types — «типы не сошлись». expected u32, found () — «ждали число, а получили ()». Что за (), расскажем чуть позже. Главное — последняя строка подсказки: remove this semicolon, то есть «уберите эту точку с запятой». У Rust очень понятные сообщения; привыкайте читать их целиком.

Выражения и инструкции

Откуда берётся странное правило про точку с запятой? Из различия, которое в Rust важнее, чем в других языках. Разберём его на бытовом примере: в рецепте есть шаги-действия («нарежьте лук», «поставьте сковороду на огонь») и есть шаги, которые дают готовую вещь («получится золотистая зажарка»). Действие что-то делает, но вещи не отдаёт; вещь можно положить в тарелку.

Официально: инструкция (statement) — действие, которое ничего не возвращает; выражение (expression) — фрагмент кода, который вычисляется в значение. 5 + 1 — выражение (значение 6), total(may) — выражение (число), let a = 5; — инструкция (объявляет переменную, но значения не даёт).

Теперь важное: точка с запятой превращает выражение в инструкцию: вычисленное значение выбрасывается. Поэтому a * 3 (без точки с запятой) отдаёт шесть, а a * 3; эту шестёрку отбрасывает.

И ещё одно неожиданное для новичка: блок кода в фигурных скобках { ... } тоже выражение. Его значение — последнее выражение внутри, снова без точки с запятой.

Rust · main.rs
fn main() {
    let x = {
        let a = 2;
        a * 3
    };
    let y = {
        let b = 2;
        let c = b * 3;
    };
    println!("{}", x);
    println!("{:?}", y);
}

Разбор по строкам

  • let x = { — справа от равно стоит блок — а блок можно использовать как значение
  • let a = 2; — внутри блока своя переменная; это инструкция, значения она не даёт
  • a * 3 — последнее выражение без точки с запятой: оно и становится значением блока, поэтому x равен 6
  • let c = b * 3; — последняя строка второго блока — инструкция, значения у блока нет, и y получает «ничего»
  • println!("{:?}", y); — формат {:?} знаком вам по первому модулю: он напечатает это «ничего» как ()

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

Функция без результата

Не каждая функция обязана что-то отдавать. Кнопка «промыть кофемашину» ничего не выдаёт: она просто выполняет работу. В Rust это функция, у которой нет стрелки -> и типа после неё. Так мы вынесем в функцию печать шапки программы:

Rust · main.rs
fn print_header() {
    println!("Копилка 0.1");
    println!("Учёт расходов за месяц");
    println!();
}

fn main() {
    print_header();
    let nothing = print_header();
    println!("{:?}", nothing);
}

Разбор по строкам

  • fn print_header() { — скобки пустые: параметров нет; стрелки нет: результата нет
  • println!(...); — обе строки — инструкции с точкой с запятой, последнего выражения в теле нет, значит, ничего не возвращается
  • print_header(); — вызов тоже оканчивается точкой с запятой: результат (его нет) выбрасывать нечего, зато вызов становится самостоятельной строкой программы
  • let nothing = print_header(); — можно даже сохранить «результат» в переменную, хотя пользы в этом нет — нужно лишь, чтобы показать, что там лежит
  • println!("{:?}", nothing); — напечатает ()

Это и есть (), который мы видели в ошибке: единичный тип (unit type), пустой кортеж, то есть «значение, в котором нет ничего». Функция без стрелки на самом деле возвращает (), просто писать это не нужно. Отсюда и ошибка с лишней точкой с запятой: функция total обещала u32, а вышел ().

Что запомнить про ()

Если компилятор говорит found (), значит, где-то вы получили «ничего» там, где ждали значение, и чаще всего виновата точка с запятой в конце строки, которая должна была отдавать результат.

Практика: функции в Копилке

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

  1. Откройте папку проекта kopilka в редакторе и файл src/main.rs.
  2. Замените его содержимое кодом ниже. Константа MONTH_LIMIT по-прежнему стоит над всеми функциями, на верхнем уровне файла.
  3. В терминале, находясь в папке kopilka, выполните cargo run.
Rust · main.rs
const MONTH_LIMIT: u32 = 30_000;

fn print_header() {
    println!("Копилка 0.1");
    println!("Учёт расходов за месяц");
    println!();
}

fn total(expenses: [u32; 3]) -> u32 {
    expenses[0] + expenses[1] + expenses[2]
}

fn percent(spent: u32, limit: u32) -> u32 {
    spent * 100 / limit
}

fn left(limit: u32, spent: u32) -> u32 {
    limit - spent
}

fn main() {
    print_header();

    let expenses = [350, 1200, 99];
    let spent = total(expenses);
    let percent_exact = spent as f64 * 100.0 / MONTH_LIMIT as f64;

    println!("Лимит месяца:  {:>6} руб.", MONTH_LIMIT);
    println!("Потрачено:     {:>6} руб.", spent);
    println!("Осталось:      {:>6} руб.", left(MONTH_LIMIT, spent));
    println!("Использовано лимита: {}%", percent(spent, MONTH_LIMIT));
    println!("Точнее: {:.1}%", percent_exact);
}

Разбор по строкам

  • fn print_header() — печатает название, подзаголовок и пустую строку; ничего не принимает и не возвращает
  • fn total(expenses: [u32; 3]) -> u32 — складывает три траты через индексы; цикл, который заменит ручное сложение, будет в одном из следующих уроков
  • fn percent(spent: u32, limit: u32) -> u32 — целая доля потраченного от лимита
  • fn left(limit: u32, spent: u32) -> u32 — остаток лимита; сначала лимит, потом потрачено, и в вызове left(MONTH_LIMIT, spent) порядок тот же
  • let expenses = [350, 1200, 99]; — массив трат; поскольку total ждёт [u32; 3], числа автоматически становятся u32
  • let percent_exact = ... — строка из практикума первого модуля, её не трогаем: точный процент с дробной частью
  • let spent = total(expenses); — вызов нашей функции вместо ручного сложения трёх индексов
  • percent(spent, MONTH_LIMIT) — вызов прямо внутри println!: выражение вычисляется, и число подставляется в {}
Вывод
Копилка 0.1
Учёт расходов за месяц

Лимит месяца:   30000 руб.
Потрачено:       1649 руб.
Осталось:       28351 руб.
Использовано лимита: 5%
Точнее: 5.5%

У left пока самое простое тело, обычное вычитание. Если потрачено больше лимита, получится отрицательное число, а в u32 оно не помещается: программа упадёт с паникой attempt to subtract with overflow. Компилятор такую беду заранее не заметит, потому что значения параметров функции становятся известны только во время работы. Мы починим left в уроке про условия.

Дальше немного поэкспериментируйте: так функции запоминаются лучше всего. Добавьте в массив другое число и посмотрите, как изменятся строки. Затем намеренно поставьте точку с запятой после expenses[0] + expenses[1] + expenses[2] и прочитайте ошибку, а потом уберите её. Ещё попробуйте вызвать total с массивом из двух чисел и посмотрите, чем ответит компилятор.

Проверьте себя

  • Чем параметр отличается от аргумента? Назовите оба в строке percent(spent, MONTH_LIMIT).
  • Почему в конце тела total нет точки с запятой и нет слова return?
  • Что получит y в let y = { let b = 1; b + 1; }; и почему?
  • Что изменится, если поменять местами аргументы в вызове percent, и заметит ли это компилятор?
  • Где в программе объявлены функции и почему main может стоять выше или ниже них?

Частые ошибки

1. Точка с запятой после результата

Что видно. Сообщение error[E0308]: mismatched types ... expected u32, found () и подсказка remove this semicolon.

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

Как исправить. Уберите ; в конце последней строки, которая должна быть результатом.

2. Не указан тип параметра

Что видно. Для заголовка fn percent(spent, limit: u32) компилятор пишет:

Ошибка компилятора
error: expected one of `:`, `@`, or `|`, found `,`
 --> src/main.rs:1:17
  |
1 | fn percent(spent, limit: u32) -> u32 {
  |                 ^ expected one of `:`, `@`, or `|`

Почему. Rust требует тип у каждого параметра, догадываться он не будет. Как исправить. Допишите двоеточие и тип: spent: u32.

3. Не то число аргументов или не тот порядок

Что видно. Если аргументов не хватает, получите error[E0061]: this function takes 2 arguments but 1 argument was supplied, а если вместо целого числа подставить дробное, то error[E0308]: mismatched types. А вот перепутанные местами аргументы одного типа ошибки не дадут вовсе.

Почему. Компилятор сверяет количество и типы, но не смысл. Как исправить. Сверяйтесь с объявлением функции: порядок аргументов совпадает с порядком параметров.

4. Имя функции не в snake_case

Что видно. Функция с именем Total_Sum работает, но компилятор предупреждает:

Ошибка компилятора
warning: function `Total_Sum` should have a snake case name
 --> src/main.rs:1:4
  |
1 | fn Total_Sum(a: u32) -> u32 { a }
  |    ^^^^^^^^^ help: convert the identifier to snake case: `total_sum`

Почему. Это warning (предупреждение), а не error: программа соберётся, но стиль Rust велит писать функции строчными буквами с подчёркиваниями. Как исправить. Переименуйте функцию, как подсказывает компилятор: total_sum.

Функция внутри main

Если написать fn внутри main, программа соберётся, но такую функцию видно только внутри этой main. Объявляйте функции на верхнем уровне файла, рядом с main: тогда их можно вызывать откуда угодно.

Что важно запомнить

  • Функция объявляется так: fn имя(параметр: тип, ...) -> тип_результата { тело }. Имя пишут в snake_case.
  • Типы параметров обязательны. Параметры стоят в объявлении, аргументы — в вызове, порядок и количество должны совпадать.
  • Результат функции — последнее выражение тела без точки с запятой; return нужен для раннего выхода.
  • Выражение вычисляется в значение, инструкция выполняет действие. Точка с запятой превращает выражение в инструкцию.
  • Блок { ... } тоже выражение. Функция без стрелки -> возвращает () — «ничего».
  • Ошибка expected u32, found () почти всегда означает лишнюю точку с запятой.

Следующий шаг — научить программу принимать решения: в норме мы или уже превысили лимит. Для этого нам понадобятся условия if и else.

На следующий урок