1. Pair/Triple и контракт результата
Если честно, то почти любая полезная функция в реальной жизни — немного “жадина”: ей часто хочется вернуть не одно значение, а сразу несколько. Например, вы разобрали строку и хотите вернуть и результат, и “хвост” строки. Или посчитали минимум и максимум. Или нашли координаты, а также сразу рассчитали что-то по ним. И вот тут возникает конфликт: функция в Kotlin всегда возвращает одно значение.
Но, как вы уже наверное догадываетесь, в этом конфликте нет трагедии: одно возвращаемое значение может быть контейнером, в котором лежит несколько частей. Pair и Triple как раз и существуют, чтобы “связать в пачку” 2–3 значения и вернуть их одной посылкой.
Возвращаем контейнер, а не “одно число”
Ранее вы привыкли, что возвращаемое значение — это что-то вроде Int, String или Boolean. Но Kotlin не ограничивает вас “примитивами”: возвращаемым значением может быть и Pair, и Triple.
Например, если функция вычисляет минимум и максимум из двух чисел, она может вернуть Pair<Int, Int>, где по контракту “первое — минимум, второе — максимум”:
fun minMax(a: Int, b: Int): Pair<Int, Int> {
return if (a < b) a to b else b to a
}
fun main() {
val result = minMax(10, 3)
println(result.first) // 3
println(result.second) // 10
}
Здесь a to b — это создание Pair(a, b) в инфиксной форме (то есть “между значениями”), и это буквально вызов функции to в стандартной библиотеке.
С точки зрения компилятора всё честно: функция вернула одно значение — объект Pair. А для нас, людей, это “две связанные штуки в одном пакете”.
Контракт результата: главный договор
Когда вы возвращаете Pair или Triple, вы как будто говорите вызывающему коду: “Я верну тебе коробку. В ней два/три отделения. И ты должен знать, что лежит в каком”. Это и есть контракт результата: договорённость о смысле и порядке компонентов.
Самая распространённая ловушка тут такая: first/second/third — имена технические. Они не несут смысла предметной области. Смысл появляется только из контекста: как вы назвали переменные, где вы распаковали результат, как вы задокументировали функцию.
Посмотрите на контраст:
fun minMax(a: Int, b: Int): Pair<Int, Int> = if (a < b) a to b else b to a
fun main() {
val x = minMax(10, 3)
println(x.first) // 3
println(x.second) // 10
}
Это рабочий код, но читается он так себе: “x.first — это что, минимум? начало диапазона? x-координата?”.
А теперь то же самое, но с нормальными именами:
fun minMax(a: Int, b: Int): Pair<Int, Int> = if (a < b) a to b else b to a
fun main() {
val (min, max) = minMax(10, 3)
println("min=$min max=$max") // min=3 max=10
}
Технически всё то же. Но теперь контракт результата “вытащен наружу” и стал очевидным: min и max подписаны прямо в месте использования. Это почти как комментарий, только комментарий, который компилируется.
Чтобы было проще держать это в голове, удобно представлять контракт Pair/Triple маленькой табличкой прямо у себя в голове (или в комментарии над функцией):
| Функция возвращает | first означает | second означает | third означает |
|---|---|---|---|
для диапазона |
|
|
— |
для min/max |
|
|
— |
для статистики |
|
|
|
И да, порядок — это часть контракта. Если вы один раз решили “first — это min”, то дальше так и живёте. Не “сегодня min/max, завтра max/min, потому что настроение поменялось”.
Деконструкция как документация в месте вызова
Многие думают, что деконструкция — это просто “чтобы короче”. На самом деле в учебном и командном коде это часто про другое: чтобы понятнее.
Когда вы пишете:
val (min, max) = minMax(10, 3)
вы одновременно делаете две вещи. Во-первых, распаковываете Pair. Во-вторых, документируете контракт: в first лежит минимум, в second — максимум.
Тут полезно выработать привычку: если вы возвращаете Pair/Triple из функции, то в месте вызова почти всегда лучше сразу деконструировать результат. Тогда дальше по коду вы работаете с нормальными переменными, а не с “анонимными компонентами”.
2. Практика: парсим строку и возвращаем 2–3 значения
Давайте продолжим наш учебный консольный “мини-парсер профиля”. Идея простая: пользователь вводит строку вида:
"Ada Lovelace, 36" Мы же хотим получить отдельно имя, фамилию и возраст.
Шаг 1: делим строку по запятой и возвращаем две части
Сначала выделим очень маленькую полезную функцию: “раздели строку по первой запятой”.
Контракт: first — левая часть, second — правая часть (обе уже trim()).
fun splitByComma(text: String): Pair<String, String> {
val commaIndex = text.indexOf(',')
if (commaIndex == -1) return text.trim() to ""
val left = text.substring(0, commaIndex).trim()
val right = text.substring(commaIndex + 1).trim()
return left to right
}
fun main() {
val (a, b) = splitByComma(" Ada Lovelace, 36 ")
println("a='$a' b='$b'") // a='Ada Lovelace' b='36'
}
Обратите внимание: мы не усложняем жизнь. Если запятой нет — возвращаем правую часть пустой строкой. Это тоже контракт, просто “контракт для плохого ввода”.
Шаг 2: парсим возраст и возвращаем “значение + сообщение”
Иногда полезно вернуть не только данные, но и пояснение, что произошло. Например: распарсили возраст — отлично. Не распарсили — вернули null и текст “возраст не число”.
Контракт: first — Int? (число или null), second — сообщение для пользователя/лога.
fun parseAge(text: String): Pair<Int?, String> {
val trimmed = text.trim()
if (trimmed.isEmpty()) return null to "Возраст не указан"
val age = trimmed.toIntOrNull()
if (age == null) return null to "Возраст должен быть числом"
if (age < 0) return null to "Возраст не может быть отрицательным"
return age to "OK"
}
fun main() {
val (age, message) = parseAge(" 36 ")
println("age=$age message='$message'") // age=36 message='OK'
}
Здесь важный момент про контракт: мы договорились, что "OK" означает успех. Можно было бы вернуть Boolean вместо строки (например Pair<Boolean, Int>), но строка удобнее для учебного примера: мы сразу видим “почему не получилось”.
Шаг 3: собираем всё вместе и возвращаем Triple
Теперь сделаем функцию, которая возвращает три результата: имя, фамилию и возраст (возраст может быть null, если не удалось распарсить).
Контракт: first — firstName, second — lastName, third — ageOrNull.
Чтобы не уходить в сложные строковые операции, фамилию будем искать по пробелу (первый пробел разделяет имя и фамилию). Если пробела нет — фамилия будет пустой строкой.
fun parseNameAge(line: String): Triple<String, String, Int?> {
val (fullName, ageText) = splitByComma(line)
val spaceIndex = fullName.indexOf(' ')
val firstName = if (spaceIndex == -1) fullName.trim() else fullName.substring(0, spaceIndex).trim()
val lastName = if (spaceIndex == -1) "" else fullName.substring(spaceIndex + 1).trim()
val (age, _) = parseAge(ageText)
return Triple(firstName, lastName, age)
}
fun main() {
val (first, last, age) = parseNameAge("Ada Lovelace, 36")
println("first='$first' last='$last' age=$age") // first='Ada' last='Lovelace' age=36
}
Да, Triple здесь выглядит уместно: три части одной сущности “профиль”. Но обратите внимание, что Triple не делает наш контракт понятным сам по себе. Понятным его сделали мы: распаковали результат в first/last/age, а не в a/b/c.
Мини-схема: от ввода до использования
Иногда полезно закрепить в голове общий процесс. Он выглядит примерно так:
flowchart TD
A["Ввод строки (readln)"] --> B["parseNameAge(line) -> Triple"]
B --> C["val (first, last, age) = ..."]
C --> D["Используем first/last/age в логике"]
И вот здесь Pair/Triple круто работают: функция возвращает один объект, но вы сразу “раскладываете его по полочкам” в месте вызова.
3. Когда Pair/Triple начинают ухудшать читаемость
На первых шагах Pair ощущается как подарок: меньше кода, всё компактно. Но есть момент, когда компактность начинает работать против вас.
Частый симптом: вы ловите себя на том, что пишете что-то вроде result.first и уже не помните, что там лежит. Или у вас появляются переменные p, x, t, res, и вы начинаете общаться с кодом как археолог: “интересно, что хотел сказать автор этой надписью на стене”.
Есть и более явный красный флаг: когда вы возвращаете Pair/Triple, но вынуждены писать длинный комментарий “first — это…, second — это…, third — это…”, потому что иначе никто не поймёт. Это значит, что вашему коду уже просится “именованный результат” (отдельный тип, где поля называются по смыслу).
Мы пока не будем вводить свои типы данных — это тема следующих больших блоков курса — но идею стоит запомнить: Pair/Triple хороши как быстрый контейнер, а не как официальный формат данных.
Правила хорошего тона
Когда вы возвращаете 2–3 значения через Pair/Triple, старайтесь мыслить не “как бы впихнуть всё в контейнер”, а “как бы сделать контракт очевидным”.
Во-первых, фиксируйте порядок компонентов как часть контракта и не меняйте его в зависимости от настроения. Если minMax возвращает (min, max), то так и должно быть всегда.
Во-вторых, почти всегда деконструируйте результат в месте вызова и давайте компонентам имена по смыслу. val (min, max) = ... в разы читабельнее, чем val p = ... и дальше p.first.
В-третьих, не делайте Triple “мешком всего”. Три значения должны быть частями одного смысла. Если вы возвращаете Triple(userName, taxRate, planetName), то это не “контракт”, а начало плохого анекдота.
4. Типичные ошибки
Ошибка №1: плавающий порядок компонентов.
Очень легко однажды вернуть (max, min), потому что “так получилось”, а в другом месте ожидать (min, max). Снаружи оба варианта выглядят одинаково: Pair<Int, Int>. Компилятор вас не спасёт. Спасёт только дисциплина: один раз зафиксировали контракт и придерживаемся его везде.
Ошибка №2: бессмысленные имена при деконструкции.
Формально val (a, b) = minMax(10, 3) работает. Но через день вы сами же будете смотреть на a и b как на “что-то первое” и “что-то второе”. Деконструкция сильна именно тем, что вы можете назвать компоненты осмысленно: val (min, max) = ....
Ошибка №3: Pair/Triple используются для несвязанных данных.
Pair — это не “двухместная тележка для всего подряд”, а контейнер для связанного результата. Если значения не являются частями одного смысла, то чтение кода превращается в угадайку, а любая правка — в риск перепутать компоненты.
Ошибка №4: возвращаем Triple, хотя нужна только одна часть.
Иногда начинающие программисты делают “универсальный результат” на все случаи жизни, а потом в 80% мест используют только first. Это сигнал, что дизайн результата не совпадает с реальным использованием. Либо возвращайте ровно то, что нужно, либо пересмотрите контракт.
Ошибка №5: попытка “спрятать” смысл внутрь first/second/third.
Иногда хочется думать, что first — это “главное”, а second — “второстепенное”. Но для Kotlin это просто “первое по порядку” и “второе по порядку”. Не приписывайте контейнеру магию. Магия должна быть в ваших именах переменных и в понятном контракте функции.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ