Ох, давно я ничего не писал! Я пытался, честно. Я начинал писать обширную статью-сравнение Unity и UE4. Я начинал писать цикл статей про некоторые внутренности UI в Unity. К тем статьям я подходил основательно: составлял список тем, на которые потенциально мог бы что-то написать. Выбирал наиболее актуальную. Потом составлял примерный план. И систематически его разворачивал — ну все как положено! А в итоге все ушло в мусор. Как-то не вышел каменный цветочек.
А на днях меня посетила спонтанная идея: к чему должно быть ближе имя метода — к вызывающему коду или к вызываемому? Возможно, у вас это не вызывает никаких вопросов, вы знаете верный ответ. И, черт возьми, немалые шансы, что вы угадали! Но все же, давайте немного поразмышляем об этом. Я, честно, не уверен, что мы к чему-то придем, и что эта статья не повторит историю своих предшественниц. Но попробовать хочется.
Сверху?
Давайте сразу к примерам. Поскольку я в последнее время работаю над играми, то и примеры у меня будут игровые. Представьте такую задачку: в игре есть автоматическая турель, стреляющая по близлежащим противникам. С некоторой периодичностью эта турель должна находить вокруг себя наиболее актуальную цель, согласно каким-то там критериям актуальности. Допустим, мы напишем это как-то так:
Хороший код?
Оу, уверен, вы сможете обкласть его по-всякому! И вы… будете в чем-то правы. Но лишь отчасти. Потому что в отрыве от контекста обсуждать недостатки этого (как и любого другого!) кода не имеет смысла. Если наш класс действительно состоит из этих 20 строчек, то это просто отличный класс! Ничего в нем трогать не надо, все просто офигенно. Больше такого в продакшен!
Но вернемся к нашему примеру. Функция FindBestTarget — это пример того, что я называю “название сверху”. Мы написали название в терминах вызывающей функции. Ей нужно найти лучшую цель, мы пишем — найти лучшую цель. Все просто, прямолинейно и логично? Практически. В таком простом примере сложно представить, что тут что-то может быть не так. Потому давайте попробуем зайти с другой стороны.
Снизу?
Теперь представим другой пример. Мы пишем функцию, которая по текущему положению и ускорению предсказывает положение объекта спустя заданное время. В контексте нашей задачи не важно, движется объект в вакууме или в какой-то среде. Не важно, имеет он форму и массу или это просто точка. Давайте представим самый простой вариант.
Вы, наверное, заметили, что в отличие от предыдущего примера, я специально не показал контекст использования этой функции и ничего не говорил о том, для чего мы это предсказание делаем. Давайте исправим это. Скажем, предсказание нам нужно для того, чтобы нарисовать голографическую фигуру в будущем положении объекта:
Вроде бы теперь все похоже на предыдущий пример? Вы можете сказать, что, в отличие от предыдущего примера, PredictFurtherLocation не является членом класса. Все верно. Потому что эта функция используется в нескольких местах. И именно поэтому я изначально описывал задачу в более абстрактных терминах.
Все кажется логичным? Функцию, решающую одну конкретную задачу в одном месте мы называем в терминах вызывающей стороны. Функцию, решающую абстрактную задачу, вызываемую из разных мест, мы называем в терминах абстрактной задачи.
Если в дальнейшем какому-то из клиентов потребуется предсказывать позицию как-то по-другому, учитывать больше факторов, то нам придется поменять этого клиента. Мы не можем менять реализацию общей функции, т.к. она используется кем-то еще. Да и это было бы странно — нам бы пришлось поменять ее название, поскольку сама задача скорее всего изменится.
Пока что все выглядит так, будто тут нет правых и неправых, что в проектировании является обычным делом. Нужно искать компромисс от случая к случаю. Если мы пишем что-то более широко используемое, библиотечное, универсальное — именуем снизу. Если мы пишем какой-то закрытый метод класса, решающий очень конкретную подзадачу этого класса, именуем сверху. Так?
Неожиданный поворот заключается в том, что лично мне один из этих подходов кажется более правильным. По крайней мере на данном этапе понимания программирования. Но перед этим давайте я сначала поясню, почему я использую слова “сверху” и “снизу”.
Сила абстракции
В программировании мы всегда используем абстракцию. Без нее мы не могли бы строить столь сложные системы. Мы берем огромную, адски сложную задачу и дробим ее на задачи поменьше. А те – на еще более мелкие. И так далее, уровень за уровнем. Конечно, сам процесс разработки обычно выглядит не так, но конечный результат в итоге получается таким.
В конечном итоге мы всегда имеем набор процедур (функций, методов), вызывающих друг друга. Каждую конкретную цепочку вызовов мы можем представить в виде стека1.
Серые прямоугольники здесь обозначают тела функций, а черные стрелочки отражают тот факт, что нижележащая функция вызывается из тела вышележащей. И тут возникает вопрос: к чему относится название? К прямоугольнику снизу? Или к прямоугольнику сверху?
Теперь, когда вы поняли, откуда я взял “снизу” и “сверху”, можно вернутся к тому, почему я отдаю предпочтение именованию снизу. Давайте представим, что при именование сверху мы подтягиваем имя функции к верхнему блоку (вызывающему), а при именование снизу — к нижнему (вызываемому). Если мы будем смешивать эти два подхода, у нас получится странная несистемная мешанина:
Мы ведь не хотим хаос? Мы не любим хаос. Мы должны наводить порядок. И один из вариантов порядка – прижать все вниз2:
Может показаться странным, что я говорю о коде в терминах геометрии. Лично мне это странным не кажется, но давайте все же вернемся к более привычным категориям. К абстракциям.
В чем же сила абстракции? Почему без нее мы с трудом пишем быструю сортировку, а с ней делаем 3D-шутер с офигенной графикой, звуком и всем-всем-всем? Потому что она позволяет не думать о деталях. Мы каждую задачу превращаем в простую, чуть ли не детскую задачку, состоящую из всего нескольких элементов. Взять список и отфильтровать по такому-то критерию. Сравнить 2 элемента и выбрать минимальный. Перебрать всех врагов и найти того, кого атакуют больше всех.
Так вот когда я работаю с функцией, хочу ли я действительно знать о том, кто ее вызывает и зачем? Ну то есть я, как человек, проектирующий и вызываемую и вызывающую функции, конечно хочу. Я не хочу делать ненужные функции. Я не хочу делать функции, которые решают не совсем те задачи. Я хочу делать ровно те функции, которые нужны для решения задачи. Но когда я определился с тем, какую задачу мне надо решить, я хочу забыть про максимальное количество окружающих деталей! Я хочу решать эту задачу максимально абстрактно, маскимально чисто.
Так что, в примере с турелью, нужно переименовать функцию поиска лучшей цели? Возможно. Давайте попробуем:
Обратите внимание, как изменилась строка, вызывающая функцию. Теперь она говорит, что в качестве текущей цели мы назначаем ближайшую. Это ведь упрощает понимание кода, разве нет? Теперь нам не нужно открывать FindBestTarget и читать код, чтобы узнать, что “лучший” всего лишь означает “ближайший”. Мы вообще избавились от понятия “лучший”! А зачем оно, собственно, было нужно?
Задел на будущее?
Но ведь мы делаем игру и в процессе разработки постоянно пробуем разные варианты, чтобы найти то, что работает, создает правильный игровой опыт. И вот мы решили, что турели, стреляющие по ближайшему игроку раздражают игрока своей тупостью и вместо этого хотим попробовать турели, стреляющие по самому дохлому врагу.
Что нужно сделать с кодом, чтобы претворить такое изменение в жизнь?
Очевидно, FindNearestTarget становится не очень актуальной. Скорее всего мы ее либо трансформируем, либо и вовсе напишем новую. В любом случае, она поменяет название3. А, значит, нам нужно будет поправить и место ее использования. Конечно, в современных IDE это делается на раз-два, но все же это дополнительные изменения.
А что, если бы мы оставили название FindBestTarget? О, совсем другое дело! Нам всего лишь надо слегка переписать алгоритм. Все изменения внутри в одной функции, в одном месте! Разве это не мечта?
Так и что же, именование сверху лучше приспособлено к будущим изменениям? На самом деле это обманчивое впечатление. Чтобы понять почему это так, нужно копнуть немного глубже в причины того, почему с FindBestTarget измененить код было легче. За счет чего это произошло?
За счет менее удачного имени, усложняющего понимание имеющегося кода. Оно вводит дополнительное понятие, которое не очень помогает пониманию задачи. И заставляет вас открывать реализацию метода, чтобы понять, какая именно цель выбирается.
А почему изменение с FindNearestTarget было сложнее? Потому что мы попытались сохранить понятный код понятным. Название функции до изменения подсказывало, что она делает. Название функции после изменения также подсказывает, что она делает. Ведь поведение функции изменилось, разве не логично, что вместе с этим поменялось и название?
Но ведь это рушит абстракцию, скажете вы. Зачем вызывающему коду знать о том, что поведение функции изменилось? Вот есть у меня Sort, какая мне разница, что у него внутри — bubble sort или quick sort? Согласен. В этом случае разницы нет, поскольку контракт функции не меняется. На всей области определения эти функции возвращают абсолютно одинаковые результаты. Но разве это так в случае с FindNearestTarget? Возвращаемое значение функции в корне поменялось! Конечно, иногда самый дохлый и самый ближний враг – один и тот же юнит. Но это же просто совпадение.
Итоги
Вот примерно поэтому я склоняюсь к тому, чтобы именовать функции снизу. Давайте им имена, точно описывающие то, что они делают. Не вскрывайте детали реализации, но и не привносите дополнительных понятий. Просто опишите конечный результат. С тем контекстом, который имеет смысл для человека, читающего код этой функции. Не с тем контекстом, который имеет смысл для вызывающей стороны. Функция, вычисляющая корень, не обязана знать, что корень нужен для нахождения расстояния. Функция нахождения расстояния не должна знать, что расстояние нужно для поиска ближайшей цели. Функция поиска ближайшей цели не должна знать, что она нужна для турели. Именно в этом и есть суть абстракции.