Зачем нужно одно и то же имя для разных случаев
Представьте, что вы пишете функцию, которая выбирает более выгодную из двух цен, а потом
понадобилась такая же функция для трёх цен. Можно назвать вторую 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 — компилятор выберет нужную по количеству переданных аргументов.
Новый смысл для операторов
Нужно расширить возможности двух операторов под конкретную задачу.
- Оператор
*уже умеет умножать числа, но не умеет повторять строку заданное число раз. Реализуйте перегрузкуfunc * (text: String, times: Int) -> String, которая возвращает строкуtext, повторённуюtimesраз подряд без разделителей. Еслиtimesменьше либо равно нулю, результат — пустая строка. - В коде уже объявлен новый оператор
<->для целых чисел. Реализуйте функциюfunc <-> (a: Int, b: Int) -> Int, которая возвращает разницу междуaиbпо модулю (то есть результат не зависит от того, какое число больше).
Декларацию infix operator <-> менять не нужно, она уже на месте.