$ sudo teach IT

Зачем нужно одно и то же имя для разных случаев

Представьте, что вы пишете функцию, которая выбирает более выгодную из двух цен, а потом понадобилась такая же функция для трёх цен. Можно назвать вторую bestPriceOfThree, но тогда придётся помнить два разных имени для одной и той же идеи «выбери самое дешёвое». Swift позволяет оставить одно и то же имя для нескольких функций, если у них разный набор параметров — компилятор сам разберётся, какую из них вызвать в каждом конкретном месте.

Это называется перегрузкой (overloading). Перегружать можно не только обычные функции, но и операторы вроде + или * — ведь в Swift оператор это тоже функция, просто с необычным именем и особым способом вызова.

Перегрузка функций

У функции есть сигнатура — имя, метки и типы параметров. Именно по сигнатуре, а не по телу функции, компилятор решает, какой вариант вызвать. Две функции с одинаковым именем считаются перегрузками, если их сигнатуры различаются: разным количеством параметров, разными типами параметров или разными метками аргументов.

func bestPrice(_ a: Int, _ b: Int) -> Int {
    return min(a, b)
}

func bestPrice(_ a: Int, _ b: Int, _ c: Int) -> Int {
    return min(a, min(b, c))
}

bestPrice(120, 95)        // вызовется первая версия — два аргумента
bestPrice(120, 95, 150)   // вызовется вторая версия — три аргумента

Компилятор смотрит на вызов bestPrice(120, 95) и видит: аргументов два, оба Int — значит подходит первая перегрузка. Он делает этот выбор ещё на этапе компиляции, никакой проверки во время выполнения программы для этого не нужно.

Перегружать можно и по меткам аргументов, даже если типы и количество параметров совпадают:

func move(distance meters: Int) -> String {
    return "проехали \(meters) м по прямой"
}

func move(angle degrees: Int) -> String {
    return "повернули на \(degrees) градусов"
}

move(distance: 10)
move(angle: 90)

Метка аргумента — часть сигнатуры наравне с типом, поэтому move(distance:) и move(angle:) это две разные функции с точки зрения компилятора, хотя у обеих один параметр типа Int и одинаковое имя move.

Тип результата не помогает выбрать перегрузку

Есть одна ловушка: сам по себе разный тип результата не позволяет компилятору однозначно выбрать нужную перегрузку, если из контекста вызова не видно, какой тип ожидается.

func make() -> Int { return 1 }
func make() -> String { return "один" }

let x = make() // ошибка: неоднозначный вызов make()
let y: String = make() // всё в порядке — тип y подсказывает, какую версию взять

В первой строке компилятор не знает, какой тип нужен вызывающему коду, и отказывается гадать — это ошибка компиляции, а не выбор «на всякий случай». Во второй строке аннотация типа String у переменной y даёт компилятору недостающий контекст.

Операторы — тоже функции

Когда вы пишете 3 + 5, на самом деле вызывается функция с именем + и двумя параметрами. У операторов особый синтаксис вызова — без скобок и запятой, аргумент слева и справа от знака, — но с точки зрения перегрузки правила те же самые: можно определить ещё одну функцию с именем +, если её сигнатура (типы аргументов) отличается от уже существующих.

func * (text: String, times: Int) -> String {
    var result = ""
    for _ in 0..

Здесь ничего не сломалось: в стандартной библиотеке уже есть * для двух Int, для двух Double и так далее, но сочетания «строка и число» среди них не было. Новая функция просто добавляет ещё один вариант в список перегрузок символа *, не трогая старые.

Если типового варианта в стандартной библиотеке ещё нет вообще, для оператора приходится сначала объявить его декларацией infix operator — иначе компилятор не поймёт, что последовательность символов между двумя значениями вообще может быть оператором. Заодно указывается группа приоритета — она решает, что посчитается раньше при смешивании операторов в одном выражении, если не расставлять скобки.

infix operator <->: AdditionPrecedence

func <-> (a: Int, b: Int) -> Int {
    return abs(a - b)
}

let gap = 9 <-> 5 // 4, разница по модулю

AdditionPrecedence — готовая группа приоритета из стандартной библиотеки, та же, что у + и -; для нового оператора обычно достаточно взять подходящую готовую группу, а не придумывать свою.

Внутри структур и классов оператор часто оформляют как static func — это тот же самый механизм, просто функция объявлена внутри типа, а не отдельно:

struct Point {
    var x: Int
    var y: Int

    static func + (a: Point, b: Point) -> Point {
        return Point(x: a.x + b.x, y: a.y + b.y)
    }
}

Про сами структуры подробно поговорим позже, здесь важно только увидеть общий приём: и свободная функция с именем +, и static func + внутри типа — это одна и та же идея перегрузки оператора, разница только в том, где она объявлена.

Где перегрузка уместна, а где вредна

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

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

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

  • Заводить две функции, различающиеся только типом результата, и ждать, что вызов без контекста как-то сам разберётся — компилятор в этом случае сообщит об неоднозначности.
  • Использовать новый символ оператора без предварительного infix operator — код не скомпилируется с сообщением о том, что оператор не объявлен.
  • Перегружать оператор так, что он перестаёт соответствовать привычному смыслу символа (например, +, который иногда вычитает) — код становится непредсказуемым.
  • Плодить перегрузки там, где хватило бы одного параметра со значением по умолчанию — если разница между вариантами только в одном необязательном значении, обычно проще и понятнее обойтись без второй функции.

Резюме

  • Перегрузка — несколько функций с одним именем, но разной сигнатурой: количеством, типами или метками параметров.
  • Компилятор выбирает нужную перегрузку на этапе компиляции, глядя на аргументы вызова.
  • Одного различия в типе результата недостаточно для выбора перегрузки без контекста.
  • Операторы в Swift — обычные функции с необычным именем и синтаксисом вызова, поэтому их тоже можно перегружать.
  • Для совсем нового символа оператора сначала нужна декларация infix operator с группой приоритета.
  • Перегрузку стоит использовать только там, где все варианты действительно делают одно и то же по смыслу действие.

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

3 вопроса

Самая выгодная цена

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

Реализуйте две перегрузки функции bestPrice:

  • bestPrice(_ a: Int, _ b: Int) -> Int — возвращает меньшее из двух чисел;
  • bestPrice(_ a: Int, _ b: Int, _ c: Int) -> Int — возвращает наименьшее из трёх чисел.

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

Новый смысл для операторов

Нужно расширить возможности двух операторов под конкретную задачу.

  1. Оператор * уже умеет умножать числа, но не умеет повторять строку заданное число раз. Реализуйте перегрузку func * (text: String, times: Int) -> String, которая возвращает строку text, повторённую times раз подряд без разделителей. Если times меньше либо равно нулю, результат — пустая строка.
  2. В коде уже объявлен новый оператор <-> для целых чисел. Реализуйте функцию func <-> (a: Int, b: Int) -> Int, которая возвращает разницу между a и b по модулю (то есть результат не зависит от того, какое число больше).

Декларацию infix operator <-> менять не нужно, она уже на месте.