🎭

3.5. Явное лучше неявного

Скорее всего, вы не раз слышали это выражение, но в данной главе, я хочу раскрыть его суть на реальных примерах:
 
Абстракция vs Pattern Matching
Задача такая: “у нас есть парсеры данных двух видов устройств, нужно сделать функцию парсинга, которая будет использовать тот или иной вид парсера”.
Как подобная задача решается в ООП:
// # Создаем реализации первого и второго парсера class FirstTerminalParser { parse = (data: string): void => { // ... } } class SecondTerminalParser { parse = (data: string): void => { // ... } } // # Описываем общий интерфейс type Parser = { parse: (data: string) => void } // # Реализуем нашу функцию const someFunction = (data: string, parser: Parser) => { parser.parse(data) }
 
Мы воспользовались такой техникой как “Интерфейсный полиморфизм”, то есть, поскольку наши классы подходят под интерфейс, мы можем их использовать в этом месте.
 
Это одно из множества решений, доступных в ООП, но как и в большинстве из них (особенно, в самых распространненных), здесь используется “абстракция”, в виде интерфейса.
 
А вот как таже самая задача решается в Функциональном стиле:
 
// # Мы точно также можем использовать классовую нотацию, если хотим class FirstTerminalParser { parse = (data: string): void => { // ... } } class SecondTerminalParser { parse = (data: string): void => { // ... } } // # В этот раз наш интерфейс не "обобщает", а конкретно указывает "или/или" через Union Type type Parser = FirstTerminalParser | SecondTerminalParser // # Реализуем нашу функцию const someFunction = (data: string, parser: Parser) => { parser.parse(data) }
 
Как видите разница в 1 строчку, но на деле глубина в этом разнице невероятная:
 
  1. Во-первых, теперь “Parser” это не нечто абстрактное, а конкретно один из двух типов (а значит все подсказки по типам буду работать еще лучше)
  1. Во-вторых, мы теперь можем использовать Pattern Matching
 
class FirstTerminalParser { name = "FirstTerminalParser" as const; // # Добавляем поле, по которому будем делать Pattern Matching parse = (data: string): void => { // ... }; firstTerminalSpecificFunction = (): string => { // ... }; } class SecondTerminalParser { name = "SecondTerminalParser" as const; // # Такойже ключ, но другое значение parse = (data: string): void => { // ... }; secondTerminalSpecificFunction = (): number => { // ... }; } type Parser = FirstTerminalParser | SecondTerminalParser; const someFunction = (data: string, parser: Parser) => { parser.parse(data); switch (parser.name) { case "FirstTerminalParser": return parser.firstTerminalSpecificFunction(); case "SecondTerminalParser": return parser.secondTerminalSpecificFunction(); default: const arg: never = parser; // # Это так называемый Switch Safe Guard throw new Error(`Undefined ${parser}`) } };
 
Это позволяет нам в нужный момент использовать специфичные функции конкретной реализации.
 
По-началу может быть не очень очевидно, но мой огромный совет: попробуйте вместо абстракции, там где у вас есть выбор между несколькими вариантами воспользоваться Union Type и Pattern Matching и тогда вы сможете понять всю силу этого подхода.
 
И вот тут Union Type + Pattern Matching гораздо более “явное” решение, поэтому лучше склоняться к нему.
 
Рефлексия vs Кодогенерация
Рефлексия – выявление типов и значений в рантайме.
Кодогенерация – создание кода, на базе вводных данных с предустановленными типами.
 
Для простоты понимания давайте рассмотрим пример с ORM:
 
Большинство распространенных ORM работают на базе рефлексии:
 
class UserTable extends BaseClass { id: string // Primary key нашей таблицы name: string // какое-то поле primaryKey = "id" // Метаданные, которые будут нужны нашей ORM tableName = "user" // Метаданные, которые будут нужны нашей ORM } class BaseClass { primaryKey: string save() { // Данная функция чаще всего сначала ходит в БД, чтобы понять // существует ли такая сущность и если да, то тогда делает UPDATE, // если нет то INSERT. Но для этого ей нужно знать primary key. const res = this.dbConnection .from(this.tableName) // Это пример рефликсии .where({ [this.primaryKey]: this[this.primaryKey] // Еще один пример рефликсии }) // ... оставшийся код нас не особо интересует, поэтому пропустим } }
 
А теперь пример с кодогенерацией:
 
// [tablename:User] type UserTable = { id: string // [type:primaryKey] name: string }
 
Представим, что у нас есть программа, которая возьмет описание выше и сгенерирует из этого конкретный код:
 
const UserTable = { save: (user: User) => { const res = this.dbConnection .from("User") // Как вы видите, здесь уже используется кокретное значение .where({ id: user.id // Как и здесь }) // ... } }
 
То есть, кодогенерация, сгенерирует код, с конкретными значениями и не придется полагаться на проверку существования или выявление типа для работы программы.
 
Кодогенерация требует специальных программ, которые умеют генерировать тот или иной код и очень жаль, что в Node.js это неочень распространенный прием, в тот момент, как в Golang – это одна из самых главных техник.
 
Если у вас есть возможность использовать кодогенерацию, лучше обратиться именно к ней, это гораздо более “явное” решение.
 
Hooks vs Procedure & Composition
 
Примером хуков может быть .preSave или .preInsert , которые часто встречаются в ORM.
 
Основная проблема хуков: чтобы понять что они существуют и запускаются, нужно в первую очередь знать об их существовании. А это крайне “неявное” поведение.
 
Вот скажите мне, что здесь произойдет:
 
const someFn = async (email: string) => { const user = { id: uuid.v4() email, } await User.insert(user) }
 
Вы можете сказать “создается пользователь”, но на деле, если у нас стоит какой-нибудь хук на insert может произойти еще миллиард дополнительных действий (я иногда видел, что даже отправляли нотификации из хуков).
 
Сравним это с:
 
const someFn = async (email: string) => { const user = { id: uuid.v4() email, createdAt: new Date() // это должно было быть установлено в хуке } await User.insert(user) await Mailer.sendRegistrationEmail(user) // это тоже }
 
Ко всему прочему, очень тяжело контролировать поведение, которое написано в хуках. Я видел, как люди добавляли специальные поля в сущности, чтобы на базе их в хуках принимать решения о том или ином действии.
 
Но что если мы не хотим писать эту логику напрямую в функцию? Тогда можно просто создать отдельный метод, который будет этим заниматься:
 
const prepareUserForCreate = (user: User) => { user.createdAt = new Date() } const saveUserAndSendEmail = async (user: User) => { const preparedUser = prepareUserForCreate(user) await User.insert(preparedUser) await Mailer.sendRegistrationEmail(preparedUser) } const someFn = async (email: string) => { const user = { id: uuid.v4() email, } await saveUserAndSendEmail(user) }
 
И такие цепочки и композиции можно создавать в любом виде, что явным образом дает нам понимание какая логика отрабатывает в каком кейсе.
 
Запуск кода при импорте
Одна из самых ужасных практик в языках программирования: запуск кода при импорте.
 
Например, в JS / TS для этого достаточно просто написать на уровне файла интересующие операции:
 
// /numArray.ts export const numArray = [] for(let i = 0; i < 60; i++) { numArray.push(i) }
 
Когда мы импортируем данный файл, массив автоматически заполниться. И это ужасно. Потому что абсолютно некотнролируемо.
 
В любом нормальном языке подобная операция запрещена или выделена в отдельное место (например, в Go, есть функция init, которая запуститься при импорте, но ее использование признано bad practice и допустимо только в очень редких случаях).
 
Гораздо лучше сделать так:
 
// /numArray.ts export const numArray = [] export const fillArray = () => { for(let i = 0; i < 60; i++) { numArray.push(i) } }
 
Теперь вы можете контролировать когда и как он будет заполнен.
 
IoC Container vs Arguments
IoC Container забирает у вас контроль над инициализацией и пробросом зависимостей.
 
Знаете ли вы, как он будет инициализировать ваши зависимости? В какой последовательности? Какие зависимости требуются для запуска конкретного сервиса? Создаете ли вы рекурсии зависимостей?
 
Все это убирается в абстракцию IoC Container и становится “неявным”.
 
Если же, мы с вами руками инициализируем все нужные зависимости и пробрасываем их в аргументы, мы самостоятельно контролируем весь процесс работы с зависимостями.
 
Да, больше аргументов, да, придется поработать ручками, но опять же, на мой взгляд, гораздо важнее быть уверенным в том, что нужная вам зависимость была проинициализирована и передана туда, куда нужно.
Логика в конструкторах
Конструктор (причем не только классов) – нужен для того, чтобы привести данные к какому-то виду.
 
Я неоднократно встречал ситуацию, когда разработчики создавали, например, подключение к БД и коннектились прямо в конструкторе:
 
class DBConnection { constructor(public host: string, public port: number) { this.connection = DB.connect(`${host}:${port}`) // Например вот так } }
 
Есть еще шанс, что человек так не сделает, потому что не сможет сделать await перед new, но тоже самое относится и к функциям:
 
const DBConnection = async (public host: string, public port: number) => { const connection = await DB.connect(`${host}:${port}`) // Например вот так return { host, port, connection, } }
 
Не надо так делать. Процесс конструирования сущности должен быть разделен с процессом любой другой логики, например, вот так:
 
class DBConnection { constructor(public host: string, public port: number) {} connect() { this.connection = DB.connect(`${this.host}:${this.port}`) } } // ... const DBConnection = async (public host: string, public port: number) => { let connection: DB.Conncetion return { host, port, connect: () => { connection = await DB.connect(`${host}:${port}`) } } }
 
Framework vs Libs
Есть много трактовок разницы между этими двумя понятиями. Мне больше всего нравится:
 
Ваш код использует библиотеку, а framework использует ваш код
 
То есть, если раскрыть на примере, то фреймворк говорит вам места и способы написания кода, вы следуете этим правилам, а он производит магию, используя ваш код.
 
Библиотеку же мы используем сами в рамках нашего кода.
 
Вот пример framework:
 
import {app} from "framework"; app({ init: () => { // Framework заранее проинициализирует env, базу данных, возможно код // из определенных заранее заданных папок. А вот внутри уже будет ваш код. // ... } })
 
Вот пример библиотеки:
 
import {initConfig, initDb, initControllers} from "library" // В этом же случае мы инициализируем все по-отдельности самостоятельно // и собираем вместе const main = () => { const config = initConfig() const db = initDb() const controllers = initControllers() const app = initApp({ config, db, controllers }) app.start() } main()
 
Собственно, библиотеки с точки зрения дизайна более управляемые и имеют меньший «black box», что является огромным преимуществом в контексте явности, поэтому я советую при написании следующего “framework” все-таки обратиться к дизайну кода, как в библиотеках.
 
Да, придется больше всего написать, больше узнать, больше самостоятельно настроить, но явность того стоит.
 
Перегрузка
Существует 2 вида перегрузок: перегрузка функций и перегрузка операторов.
 
Перегрузка функций
 
function add(a:string, b:string):string; function add(a:number, b:number): number; function add(a: any, b:any): any { return a + b; }
Да, это, конечно, может быть удобно в каких-то кейсах, но я ни разу не встречал ситуацию при которой перегрузку функций нельзя было заменить на несколько отдельных методов:
function addStrings(a:string, b:string):string => { return a + b; } function addNumbers(a:number, b:number): number => { return a + b; }
 
Несколько дополнительных функций – гораздо более очевидная и явная вещь, чем при перегрузке.
 
Перегрузка операторов
 
Перегрузка операторов доступна в некоторых языках из коробки, например, мы можем перегрузить +, чтобы он умел мерджить массивы:
 
// Это очень условный пример Array.operator("+", (left: any[], right: any[]): any[] => { return [...left, ...right] }) // теперь используем console.log([1,2,3] + [4,5,6]) // [1,2,3,4,5,6]
 
Более распространённым видом перегрузки оператор являются Itterator и getter & setter .
 
Все эти перегрузки изменять стандартное поведение языка, из-за чего разработчик не прочитавший ваши кастомные перегрузки получит совершенно неожиданное для него поведение.
 
Мутабельность vs Иммутабельность
Дисклеймер: я не предлагаю вам использовать иммутабельность повсеместно, если это не поддерживается вашим языком из коробки. Работа с иммутабельностью требует определенного дизайна кода и архитектуры мышления, которые могут излишне усложнить кодовую базу.
 
Скажите мне, что выведет последний console.log:
 
const user = { id: 1, name: "David", age: 27 } someFunction(user) console.log(user) // Что выведет этот console.log
 
Если вы понимаете, как работает мутабельный язык, то скажете, что не знаете, потому что любое из свойств могло измениться.
 
Вот в таком простом виде можно уже понимать почему мутабельность – неясный поход. Он требует от вас изучить все функции, через которые проходит сущность, чтобы узнать будут ли над ней какие-то изменения.
 
А теперь посмотрим, если бы мы следовали иммутабельному подходу
 
const user = { id: 1, name: "David", age: 27 } // Я не буду применять какие-то хаки для добавления иммутабельности // мы просто предполагаем, что никогда не мутируем пришедшее извне состояние // поэтому функция someFunction точно не изменит user someFunction(user) console.log(user) // Здесь 100% выведется тотже самый user
 
И теперь мы точно знаем, что с этой сущностью ничего не произойдет. А если и произойдет, то мы получим новый экземпляр:
const user = { id: 1, name: "David", age: 27 } // Я не буду применять какие-то хаки для добавления иммутабельности // мы просто предполагаем, что никогда не мутируем пришедшее извне состояние // поэтому функция someFunction точно не изменит user const updatedUser = someFunction(user) console.log(user) // Здесь 100% выведется тотже самый user console.log(updatedUser) // А вот здесь уже будет какое-то изменение
 
Я не буду вдаваться в кучу разных деталей иммутабельности, но уже самый базовый пример показывает почему этот подход гораздо более явный.
 
 
Тут еще много примеров и я буду по-тихоньку их добавлять (следить за новостями здесь)

А чем лучше?

Самое сложное в работе разработчика – изучать и держать в голове всю цепочку трансформаций данных в программе.
 
Когда вы только написали код, вся его логика “загружена” в ваше сознание. Вы знаете откуда начинаются все цепочки поведения и что они используют по дороге.
 
Но когда на него будет смотреть другой разработчик (или вы же, но через какое-то время), ему надо будет построить все эти цепочки трансформаций в своей голове.
 
И тут, во-первых, любое неявное поведение может быть незамечано, что в последствии вызовет баги, а баги стоят времени, денег и нервов.
 
А во-вторых, если он и сможет найти это неявное поведение, то держать его в голове и применять ко всем кейсам – это очень тяжелый процесс.
 
Чем более явный код, тем легче его читать, понимать, изменять и развивать. А все эти характеристики конвертируются в главную метрику – радость разработчика.

Что дальше

 
Впереди последняя глава в разделе “Философия”, в которой мы выкинем асе.. абсолютно все: