1. Введение
Когда вы пишете программу-скрипт, обычно всё начинается невинно: main(), пара readln(), пара println(), и вы чувствуете себя властелином консоли. Но проходит 20 минут — и в main появляется «ещё чуть-чуть логики»: trim(), проверки, indexOf, substring, повторяющиеся куски «если пусто — ошибка», и вот уже функция выглядит как дипломная работа на тему «как потерять нить рассуждений за 30 строк».
Проблема не в том, что 30 строк — это много. Проблема в том, что эти строки часто про разные уровни абстракции одновременно: где-то вы решаете задачу («распарсить покупку»), а где-то воюете с деталями («проверить границы подстроки»). И в итоге вы читаете код как детектив без первых десяти страниц: куча всего происходит, но почему — непонятно.
Вспоминаем локальные функции
Локальная функция — это функция, объявленная внутри другой функции. Она видна только в пределах внешней функции, то есть снаружи её «как будто не существует». В Kotlin это нормальная, официальная возможность языка: локальная функция может принимать параметры, возвращать значение и вызывать другие функции — то есть быть полноценной функцией, просто с ограниченной областью видимости.
Главная идея локальной функции очень практичная: если «помощник» нужен только в одном месте, то логично держать его рядом с этим местом, а не превращать файл в музей вспомогательных утилит.
Минимальный пример «по форме»:
fun main() {
fun hello(name: String): String {
return "Hello, $name!"
}
println(hello("Kotlin")) // Hello, Kotlin!
}
Да, выглядит немного странно, потому что main() — не самое типичное место для локальных функций. Но технически это работает так же, как в любой другой функции.
Почему не всегда подходит private fun на уровне файла
Когда вы научились писать функции, появляется соблазн любую повторяющуюся логику вынести «наверх» файла как private fun. Это часто хорошее решение, и мы его не запрещаем — наоборот, private помогает держать поверхность API чистой, не показывая внешнему миру то, что ему не нужно.
Но есть тонкость: private fun сверху файла всё равно становится «соседом» для всего остального кода файла. Если таких утилит много, файл превращается в кладовку: полезно, но найти что-то сложно.
Локальная функция хороша, когда выполняются два условия.
Первое: функция-помощник реально относится только к одному сценарию и не нужна больше нигде.
Второе: имя помощника само по себе не имеет ценности на уровне всего проекта. Например, safePart, norm, parseLeft — отличные имена в контексте одной функции, но как «глобальные» имена файла они могут выглядеть слишком общо.
Где объявлять локальные функции?
Это один из тех вопросов, которые на самом деле про читаемость. Если объявить локальную функцию прямо перед использованием, вы читаете код линейно: «вот помощник — вот применение». Но если помощников несколько, вы рискуете получить «лесенку» из объявлений, разбросанных по всей функции.
На практике чаще всего удобно объявлять локальные функции в начале внешней функции, сразу после создания ключевых переменных. Тогда структура чтения становится похожей на хорошую статью: сначала «словарик терминов», потом основной текст.
Сравним два стиля на маленьком примере форматирования имени пользователя.
Вариант «помощник наверху»:
fun formatUser(first: String, last: String): String {
fun norm(s: String): String {
val t = s.trim()
if (t.isEmpty()) return "-"
return t
}
return norm(first) + " " + norm(last)
}
Вариант «помощник рядом» (тоже нормальный, просто другой):
fun formatUser(first: String, last: String): String {
val a = first.trim()
val b = last.trim()
fun show(s: String) = if (s.isEmpty()) "-" else s
return show(a) + " " + show(b)
}
Оба варианта работают. В первом norm выглядит как часть контракта функции formatUser: «внутри всё нормализуем». Во втором show выглядит как мелкая деталь форматирования прямо рядом с данными. Со временем вы выработаете стиль, но на старте лучше держаться правила: если помощник важен для понимания всей функции — объявляйте его ближе к началу.
Локальные функции: параметры и возврат
Очень важный момент, который повышает читаемость: локальная функция не обязана использовать переменные внешней функции. Да, она может так делать (и это даже иногда удобно), но сегодня мы не будем погружаться глубоко в эту тему — потому что дальше по плану будет отдельная лекция про замыкания и риски мутабельности.
Пока держим мысль: если локальной функции нужен вход — даём параметр. Если нужен результат — возвращаем значение. Тогда зависимости видны прямо в сигнатуре, и вы не превращаете код в квест «откуда взялось это значение».
Небольшой пример: есть число, мы хотим печатать его «красиво», но правило форматирования — локальная деталь.
fun printScore(score: Int) {
fun label(x: Int): String {
if (x >= 100) return "legend"
if (x >= 50) return "pro"
return "newbie"
}
println("score=$score (${label(score)})") // score=42 (newbie)
}
Локальная функция label получает x параметром. Мы могли бы использовать score напрямую, но тогда функция стала бы менее универсальной даже внутри этой маленькой задачи, и её сложнее читать: кажется, что она зависит от внешнего состояния.
2. Практический пример: парсинг MiniReceipt
Базовая версия: Pair<String, Int>?
Сейчас соберём небольшой, но цельный пример, который можно развивать дальше. Пусть наша мини-программа «MiniReceipt» читает строку вида:
Coffee, 199
И печатает «чек»:
Покупка: Coffee
Цена: 199
Мы специально берём формат попроще: одно название и одна цена, разделённые запятой. Наша цель сегодня — не разобрать все форматы ввода на свете, а показать, как локальные функции помогают сделать разбор строки читаемым и устойчивым к пробелам.
Начнём с функции, которая возвращает Pair<String, Int>?: либо «название+цена», либо null, если строка невалидна.
fun parseTitleAndPrice(line: String): Pair<String, Int>? {
fun norm(s: String): String = s.trim()
val comma = line.indexOf(',')
if (comma == -1) return null
val title = norm(line.substring(0, comma))
val priceText = norm(line.substring(comma + 1))
val price = priceText.toIntOrNull() ?: return null
if (title.isEmpty() || price < 0) return null
return title to price
}
Теперь main, который использует эту функцию и деконструирует Pair:
fun main() {
val line = readln()
val parsed = parseTitleAndPrice(line)
if (parsed == null) {
println("Ошибка: ожидаю строку вида \"Coffee, 199\"") // например
return
}
val (title, price) = parsed
println("Покупка: $title") // Покупка: Coffee
println("Цена: $price") // Цена: 199
}
Пока всё выглядит неплохо. Но давайте добавим «вредного пользователя» (то есть реального). Он введёт что-то вроде:
, 199
Или:
Coffee,
Или:
Coffee, 199, extra
Или ещё веселее: строка может быть пустой, или запятая может стоять в самом конце.
И вот тут вы обычно начинаете добавлять проверки границ, проверки пустоты, снова trim(), снова trim(), и программа превращается в кашу. Самое время написать локальную функцию, которая изолирует «опасные» детали.
Безопасные детали: safeSubstringOrNull
Сделаем локальный помощник safeSubstringOrNull. Он будет проверять границы и возвращать null, если что-то не так.
fun parseTitleAndPrice(line: String): Pair<String, Int>? {
fun norm(s: String) = s.trim()
fun safeSubstringOrNull(s: String, from: Int, to: Int): String? {
if (from < 0) return null
if (to < 0) return null
if (from > to) return null
if (to > s.length) return null
return s.substring(from, to)
}
val comma = line.indexOf(',')
if (comma == -1) return null
val rawTitle = safeSubstringOrNull(line, 0, comma) ?: return null
val rawPrice = safeSubstringOrNull(line, comma + 1, line.length) ?: return null
val title = norm(rawTitle)
val priceText = norm(rawPrice)
val price = priceText.toIntOrNull() ?: return null
if (title.isEmpty() || price < 0) return null
return title to price
}
Да, кода стало больше. Но теперь он стал «слоистым»: наверху — сценарий («нашли запятую, вырезали части»), ниже — механизм безопасности (safeSubstringOrNull). Если завтра вы решите ещё где-то внутри этой функции безопасно резать строку — вы уже не будете копировать проверки, а просто используете помощника.
Как не превратить код в «матрёшку»
Локальные функции легко полюбить, а потом… слегка злоупотребить. Есть опасность сделать «функцию внутри функции внутри функции» и гордо сказать: «Смотрите, какая инкапсуляция!» — а потом самому не понять, откуда что вызывается.
Хорошее практическое правило звучит так: локальная функция должна быть маленьким помощником, а не вторым сценарием. Если локальная функция начинает содержать много ветвлений, несколько уровней проверок и ещё пару локальных функций внутри — это сигнал, что вы либо неправильно выбрали границу, либо пора вынести часть логики в отдельную private fun на уровне файла.
Ещё один важный момент: локальная функция чаще всего должна иметь одну понятную задачу. В нашем примере safeSubstringOrNull делает только «проверить границы и вырезать». Она не форматирует результат, не печатает ошибки, не пытается угадать, что вы хотели ввести. Чем проще обязанность, тем легче доверять помощнику.
Небольшая табличка, чтобы закрепить разницу ощущений при чтении:
| Ситуация | Как обычно выглядит «плохо» | Как обычно выглядит «лучше» |
|---|---|---|
| Повторяются проверки | В трёх местах по 4 if подряд | Один локальный helper, основной код читается как сценарий |
| Helper нужен только тут | private fun на уровне файла с «общим» именем | Локальная функция внутри внешней, имя уместно в контексте |
| Helper разрастается | Локальная функция на 40 строк, половина логики в ней | Вынести наружу в отдельную private fun или разделить на 2 helper’а |
Область видимости и имена: затенение
С локальными функциями приходит новая разновидность «ой»: вы можете случайно назвать параметр локальной функции так же, как переменную снаружи. Это называется затенение (shadowing): внутри локальной функции имя будет ссылаться на параметр, а не на внешнюю переменную. Код компилируется, но читается хуже, потому что мозг постоянно спрашивает: «Так, а x сейчас какой?»
Покажу на маленьком примере. Это не ошибка компиляции — это ошибка читабельности:
fun demo() {
val price = 100
fun addTax(price: Int): Int {
return price + 20
}
println(addTax(50)) // 70
println(price) // 100
}
Формально всё ок. Но когда вы видите addTax(price), приходится дополнительно вчитываться: это внешний price или параметр? Поэтому лучше выбирать разные имена. Например, внешний price, а параметр basePrice, или внешний line, а параметр text.
И ещё один нюанс: локальная функция видна только внутри внешней, а значит вы не сможете «случайно» использовать её где-то в другом месте файла. Это хорошо: меньше шансов, что код начнёт зависеть от утилиты, которая вообще-то была создана «на один раз».
Схема: как сценарий вызывает локальные шаги
Иногда полезно не только читать код, но и видеть его как последовательность шагов. Внешняя функция — это сценарий, локальные функции — это подшаги, которые прячут детали. Если вы когда-нибудь писали рецепт еды, то это похоже на ситуацию: «в рецепте написано “приготовьте соус”, а детали соуса — отдельным блоком чуть ниже».
Вот как выглядит наш разбор строки покупки в виде простой схемы:
flowchart TD
A["parseTitleAndPrice(line)"] --> B["Найти запятую indexOf(',')"]
B -->|нет запятой| X[return null]
B -->|есть запятая| C[Вырезать rawTitle / rawPrice]
C --> D[local safeSubstringOrNull: проверки границ + substring]
D -->|ошибка границ| X
C --> E["local norm: trim()"]
E --> F["priceText.toIntOrNull()"]
F -->|null| X
F --> G[Проверить title не пустой, price >= 0]
G -->|не ок| X
G --> H[return title to price]
Если схема читается легче, чем код — это нормальная стадия. Со временем вы начнёте видеть такие «шаги» прямо при чтении, и локальные функции как раз помогают держать эти шаги отделёнными от низкоуровневой возни.
3. Типичные ошибки при работе с локальными функциями
Ошибка №1: локальная функция становится «вторым main».
Иногда начинаешь с хорошего помощника на 5 строк, а потом «чуть-чуть допишу», и вот в локальной функции уже ветвления, форматирование, проверки, и ещё пара локальных функций внутри. Внешняя функция в итоге превращается в тонкую оболочку, а вся логика — внутри помощника. Это ломает идею: помощник должен помогать, а не переезжать жить вместо хозяина.
Ошибка №2: локальная функция не имеет чёткой обязанности.
Когда helper делает и «отрезать подстроку», и «нормализовать», и «попробовать распарсить», и «сформировать сообщение об ошибке», код начинает вести себя как швейцарский нож: вроде полезно, но непонятно, где какая грань. Гораздо приятнее читать, когда helper делает одну вещь: safeSubstringOrNull — только про границы, norm — только про trim().
Ошибка №3: скрытые зависимости от внешних переменных.
Kotlin позволяет локальной функции использовать переменные внешней функции (это легально и часто удобно). Но если helper начинает зависеть от нескольких внешних var, то поведение становится неочевидным: по сигнатуре не видно, от чего зависит результат. На этом этапе лучше держать дисциплину: если значение важно — передайте его параметром. А более тонкие случаи (когда локальная функция захватывает внешние переменные и что из этого следует) мы разберём в следующей лекции.
Ошибка №4: затенение имён и путаница «какая переменная сейчас используется».
Когда снаружи val line, а внутри fun parse(line: String), мозг начинает работать как компилятор, но без оптимизаций. Код не становится неправильным — он становится тяжёлым. Простое правило спасает: не повторяйте имена внешних переменных в параметрах и локальных переменных, особенно во вложенных функциях.
Ошибка №5: локальные функции раскиданы по телу и ломают линейность чтения.
Если помощник объявлен в середине функции, потом ещё один — ближе к концу, потом вы возвращаетесь наверх и пытаетесь вспомнить, где что объявлено, чтение превращается в «скролльный спорт». Чаще всего проще объявлять локальные функции в начале внешней функции, чтобы дальше читать сценарий сверху вниз без сюрпризов.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ