Заговор разработчиков против корпораций

Речь пойдет о тайной, сугубо анонимной организации, следы которой начал замечать еще в 2018-ом, работая в Яндексе. О целях и мотивах организации можно только догадываться: некоторые считают это кибер-луддизмом, другие — техно-анархизмом. Ясно одно: организация существует, ее члены уничтожают кодовые базы десятилетиями, и говорить об этом не принято.

07994067cb037efbd75eaf62d1d56d76.png

Риск, который я на себя беру, публикуя статью, велик, но молчать больше не могу. Поделюсь приемами организации, которые удалось идентифицировать, пока работал в мобильной разработке и в бэкенде.

В серии статей будет 3 части:

  1. Тактика работы с кодовой базой (эта статья);

  2. Тактика работы с архитектурой;

  3. Тактика работы с коллективом.

Оставляю за собой свободу написать больше статей на тему, ведь постоянно изобретаются все более изощренные техники саботажа.

Если же данная статья окажется последней — значит, до меня добрались.

План статьи

Обсудим, как ревнители упомянутого выше братства маскируют вредоносный код, прячут проблемы на code review, создают абстракции с экспоненциальным ростом сложности.

Содержание:

  • Конструирование ловушек в кодовой базе;

  • Когнитивное истощение или диалектика говнокода;

  • Подрыв производительности.

Примеры кода даны на Kotlin и за небольшим исключением будут понятны разработчикам «мейнстримовых» языков, вроде Java, Go, C++, TypeScript, Swift, Dart, Python.

Я не претендую на полноту изложения, но постарался изложить то, что доставило больше всего боли.

Конструирование ловушек в кодовой базе

Поборники известной подпольной организации редко вносят критические ошибки сами, предпочитая умело расставлять ловушки, чтобы формально злодеяние оказалось на совести другого разработчика.

Ловушка через «destructuring»

Чем как ни «дестракчерингом» — читай разрушением — пользоваться, когда нужно разрушить кодовую базу? Это и концептуально, и двусмысленно, и невинно одновременно. Давайте рассмотрим пример: создается data class c полями, порядок которых в будущем захочется поменять.

data class User(
    val age: Int,
    val id: Ing, // как будто бы id должен быть первым?
    val name: String,
    val surname: String,
)

И где-то в другом файле пишется:

val (_, id) = getUser()

Если какой-то разработчик поменяет поля age и id местами в декларации класса, что в общем-то безобидно, компилятор ничего не заметит и не подскажет о проблеме. В худшем случае какое-то время вместо id будет использоваться возраст.

Как это решать?

Можно завести отдельный класс для Age, тогда изменение порядка полей приведет к ошибке компиляции (Age не пройдет туда, где ожидается Int): ±

value class UserAge(val age: Int)

Можно не использовать destructuring вовсе.

Ловушка через мета-программирование

Когда нужно добавить какую-то логику, мы идем в ожидаемое место. Бэкендеры за бизнес-логикой пойдут в handler и/или service, мобильные разработчики — в Interactor или UseCase. В redux-архитектуре бизнес-логика находится в функции reduce, а в TEA — update. Если речь про логику отрисовки деталей View, ее бы искали в реализации кастомной вьюхи. В общем, всегда ясно, куда идти.

Соответственно, с позиции технического терроризма нужно прятать логику так, чтобы о ней и не знали вовсе.

Лучший пример, с которым сталкивался, — использование AspectJ в мобильном приложении. Не очень хочу называть имена, но намекну, что статей про AOP в Яндексе не так уж и много. Суть в том, что где-то в мета-файлах, куда никто никогда не заглядывает, помещаются инструкции, привязанные к текущей формации кода в проекте. Пишется что-то такое: найди класс с именем «X» и вставь после метода «У» в месте выхода из функции следующий код.

случайный пример AspectJ из интернета.

случайный пример AspectJ из интернета.

Работать с кодовой базой, зараженной Аспектами, становится больно, ведь изменение некоторых функций может сказаться непредсказуемым образом. Приходится держать в памяти код аспектов: на какие строчки и какие методы они завязаны, что и где добавляют. Безумно хорошо аспекты «синергируют» с наследованием, когда неявная логика прячется за уровень наследования.

Возможно, я был бы менее уверен в критике подхода, если бы не оказался жертвой технического терроризма, и не наблюдал, как от Аспектов страдал каждый коллега. Что примечательно, агент саботажа покинул команду и уже через неделю насаждал хворь в другой, соседний проект.

Продают технологию под соусом поддержания чистоты: «код не будет замусорен аналитикой, логами, security-проверками». Кажется логично, что мета-задачи размещаются в мета-файлы, но на мой вкус, проблем больше:

  • Неявная и невидимая (в случае наследования) логика.

  • Дополнительный шаг компиляции.

  • Дополнительная технология, которая нигде больше не пригодится и с которой нужно разбираться, ставить плагины для чтения файлов с аспектами.

Судя по всему, к этому пришел не только я, вот как выглядит частота гугления AspectJ с 2004 года:

Сколько компаний удалось уничтожить, прежде чем технология потеряла актуальность?

Сколько компаний удалось уничтожить, прежде чем технология потеряла актуальность?

Другой пример из другого языка программирования: макросы в Clojure. Макросы позволяют добавлять все что угодно в язык. Я хорошо знаком с core языка, писал на Clojure год за деньги и еще года 3 в свободное время. Но когда попадается кодовая база, усыпанная большим количеством макросов, все покрывается туманом. Выше я перечислял проблемы использования аспектов, все те же минусы актуальны и здесь (кроме, пожалуй, невидимой логики).

Ловушка через неявную связность

Пишем в базу (в кеш, в переменную) в одном месте, читаем в другом — вместо того, чтобы передать данные явно (чисто).

suspend fun addUserToGroup(context: Context) {
    // много логики тут
    db.addUser(context) // данные о пользователе берутся из контекста
    notifyUsers(context)
    // еще логика
}

suspend fun notifyUsers(context) {
    val users = db.getUsers(context: Context)
    ws.notifyUsers(users)
}

Первая цель саботажа — дождаться, пока кто-то поменяет строчки местами:

suspend fun addUserToGroup(context: Context) {
    // ...
    notifyUsers(context)
    // логика сломается, и нужному юзеру никогда не придет notification
    db.addUser(context)
}

Вторая цель саботажа — создать гонку (race condition). Пользователю (из контекста) должна прийти нотификация, а для этого функции addUser и getUser должны быть в одной транзакции.

Обеих проблем можно было бы избежать, сделав функцию notifyUsers чистой:

suspend fun addUserToGroup(users: List) {
    // ...
    val users = transactionResult { con ->
        db.addUser(con, context)
        db.getUsers(context.groupId)
    }
    notifyUsers(users)
    // ...
}

Если вам кажется, что пример надуманный, то в оправдание скажу, что видел подобное не единожды. Вместо addUser могла быть другая модификация базы, например, deleteUser.

Ловушка через нарушение гайдлайнов и ожиданий

Еще один прием — играть на ожиданиях. Пример в Kotlin: скрывать бизнес-логику и ветвление внутри функции apply. Функция сама по себе предназначена для конфигурации объекта, и разработчик, видя ее, не подумает, что туда попадет бизнес-логика, не относящаяся к конфигурации объекта.

fun handleProducts(request: Json) {
    val products = parseJson(request).apply {
        onProductRequestReceive(this)
    }    
    service.handle(products)
    retspond(HttpStatus.OK)
}

Разработчик пойдет искать и дописывать логику в service.handle(product) и может продублировать код или внести багу. Да пусть хотя бы повозится какое-то время с «магическим» поведением — уже неплохо с точки зрения хитреца, установившего ловушку.

Когнитивное истощение или диалектика говнокода

Смысл активности в том, чтобы из каждой строчки выжимать дополнительную сложность. Разрушители кодовой базы терпеливы — их устраивает, что не сразу, но где-то на горизонте случится переход количественных изменений в качественные. Тогда кодовую базу будет поддерживать очень дорого, а порой и вовсе невозможно.

Когнитивное истощение: отрицание отрицания

Вместо того, чтобы писать прямое условие, всегда можно написать инверсное:

val enabled = isEnabled()
if (!enabled) {
    // some other logic    
} else {
    // some logic    
}

Да, это всего лишь добавляет на одну мыслительную операцию больше, но, во-первых, курочка по зёрнышку клюет, а во-вторых, оно легко масштабируется:

val disabled = isDisabled()
if (!disabled) {
    // some logic    
} else {
    // some other logic    
}

Тут уже нужно сделать две дополнительных мыслительных операции: одну на отрицание »!», вторую на мысленный перевод disabled в enabled. Кроме того, созданием метода с сигнатурой fun isDisabled() гарантируется, что во всей кодовой базе будут создаваться диалектические отрицания отрицания.

Нельзя отрицать, что ничего из отсутствующего никогда не понадобится разрушителям кодовых баз, об этом и продолжим.

Когнитивное истощение: неинтуитивные необязательные функции

Другой прием — использование функции с логикой, которую приходится каждый раз проверять. Желательно, чтобы логика была контринтуитивной, как в случае с импликацией:

infix fun Boolean.implies(other: Boolean): Boolean = !this || other

И использование:

val isRaining = false
val carryingUmbrella by lazy { getUmbrella() != null }

val ok = isRaining implies carryingUmbrella 

Для кого-то логическая импликация — очевидная вещь. Можно давить на необразованность или синдром самозванца ревьюера, чтобы защитить PR. Цель же такого кода — заставить других разработчиков при встрече с implies идти смотреть определение функции, потому что даже тот, кто знаком с концепцией, должен будет убедиться, что функцию реализована ожидаемо.

А теперь самое сладкое: только представьте, насколько хорошо можно комбинировать импликацию с отрицанием отрицания!

Попробуйте понять, есть ли ошибка в программе ниже. Должен ли user идти домой, чтобы не промокнуть, если учитываются две переменные: идет дождь, есть зонтик:

val shouldIGoHome = !noRaining implies !noUmbrella

И посмотрите, насколько проще читается:

val shouldIGoHome = if (isRaining) {
    !carryingUmbrella()
} else {
    false
}

Когнитивное истощение: go to

Казалось бы, все современные языки отказались от go to, и после выхода статьи Дейкстры «Go To Statement Considered Harmful» (кстати, тут же могу порекомендовать и статью Go statement considered harmful) только самые ярые представители техно-анархизма сопротивлялись и продолжали апологию go to. Движение было сильно, и сейчас вы можете найти злосчастный стейтмент и в JS, и в C++, хотя большинство гайдлайнов его запрещают.

Что нам более интересно, все популярные языки неявно поддерживают go to.

Для демонстрации проблемы упростим логику приложения до простого стека выполнения программы:

+ Поток выполнения
    + Логика первого уровня
        + Логика второго уровня
            + Логика третьего уровня
            - Завершение логики первого уровня
        - Завершение логики второго уровня
    - Завершение логики первого уровня
- Завершение потока выполнения

На примере бэкенда это могло бы выглядеть так:

+ Цикл обработки запросов
    + Middleware start
        + Handler function start
            + Service function start
            - Service function end
        - Handler function end
    - Middleware end 
- Закрытие сервиса

Теперь давайте представим, что нужно доработать бизнес-логику: в случаях, когда в запросе присутствует флаг deferred («отложенный»), мы не будем запускать основную логику, а сделаем вместо этого deferredLogic и вернем 202, а не 200. Изначально (упрощенный) код мог выглядеть как-то так (прошу, не обращайте внимание на «нейминг»):

class SomeService {
    // some code here

    fun someFunction(request: SomeRequest): Result {
        // more logic here
        return doLogicWithRequest()
    }

    sealed class Result {
        class Done(): Result()
        // more results
    }
}

// где-то на уровен хэндлера есть код с parrent matching по SomeService.Result
// Когда Result.Done, возвращается 200.

Чтобы реализовать логику, можно было бы дописать ветвление в ожидаемом месте:

+ Цикл обработки запросов
    + Middleware start
        + Handler function start
            + Service function start (вся новая логика тут)
            - Service function end
        - Handler function end
    - Middleware end 
- Закрытие сервиса
class SomeService {

    fun someFunction(request: SomeRequest): Result {
        return if (request.deferred) {
            doDeferredLogicWithRequest(request)
        } else {
            doLogicWithRequest(request)
        }
    }

    fun doDeferredLogicWithRequest(request: SomeRequest): Result.Deferred {
        // тут какай-то логика по отложенному запросу
    }

    sealed class Result {
        class Done(): SomeResponse() 
        class Deferred(): SomeResponse() 
        // more results
    }
}

Но это было бы слишком просто, предсказуемо, легко в поддержке, подходило бы как для unit-тестов, так и для интеграционных. Давайте взглянем на задачу с целью нанесения максимального урона кодовой базе.

+ Цикл обработки запросов
    + Middleware start
        + Handler function start
            + Service function start (часть логики тут)
                + DAO function start (часть логики тут)
                - DAO function end
            - Service function end
        - Handler function end
    - Middleware end (часть логики тут)
- Закрытие сервиса
class SomeService {
  
    fun someFunction(request: SomeRequest): Result {
        // обратите внимание, ничего не намекает, что 
        // поток выполнения может прерваться, и не видно, где он возобновится
        if (request.debug) {
            dao.processDeferred(request) // <- хотя тут кидается ошибка!
        }
        return doLogicWithRequest(request)
    }

    // тут тоже ничего не намекает на новые варианты ответов
    sealed class Result {
        class Done(): Result() 
        // ...
    }
}

class SomeDAO {
    fun processDeferred(request: SomeRequest) {
        // что-то пишем в базу
        throw SomeDefferedException() 
    }
}

И где-то на просторах «Middleware»:

catch(e: SomeDefferedException) {
    call.respond(202)
}

Обратите внимание, насколько это прекрасно: если ревьюер (который как правило занят другими задачами и торопится) посмотрит на каждый отдельный кусочек кода, все будет выглядеть приемлемо. Ну вызвали какой-то метод в сервисе. Добавили какой-то другой метод. Мало ли, понадобилось кинуть исключение. Добавилась какая-то обработка в Middleware.

Теперь представьте, что практически в каждом «хэндлере» присутствует неявная логика. Написание нового кода в предсказуемом месте может прерываться непредсказуемым образом через go to (throw). При вынесении кода на другой поток ошибка вообще потеряется.

Всегда ли исключения — плохо? Если речь о нормальном потоке выполнения, а не об ошибке, то да. Никогда не стоит реализовывать логику через исключения. Могу предположить, что адепты уничтожения корпораций вдохновляются книгами, вроде Effective Java, делая противоположное.

Use exceptions only for exceptional conditions. c. Effective Java

Когнитивное истощение: слепой try-catch

Если android-разработчик встретит код из листинга ниже, он сразу обнаружит, что Exception не обработается:

fun function() {
    // ...
    try {
        this.post {
            if (badCondition) throw BadException() 
        }
    } catch(e: BadException) {
        // какая-то обработка ошибок
    }
    // ...
}

Так как post ставит блок кода в очередь, которая обработается вне рамок try-catch. Чтобы реализовать «слепой try-catch», нужно значительно расширить его «скоуп». И возможно, в одном из мест выкинуть exception вне post, чтобы ублажить ревьюера.

Гляньте пример, все станет ясно:

fun function() {
    try {
        // какой-то код здесь
        if (badCondition) throw BadException() 
        // какой-то код здесь
        newFunction()         // <- вся хитрость тут!
    } catch(e: BadException) {
        // какая-то обработка ошибок
    }
    // ...
}

fun newFunction() {
        // ...
        this.post { // <- и вот он post, чтобы BadException не обработалось
            if (anotherCondition) throw BadException() 
        }
}

Если на ревью спросят, зачем такой широкий «скоуп» try-catch, всегда можно сказать, что Exception кидается несколько раз, зачем дублировать код? Надо DRY! Надо ли? в части про архитектуру в следующей статье об этом будет свой раздел.

Когнитивное истощение: скрытый источник данных

Обычно данные в базе данных появляются или в результате выполнения кода проекта (например, добавление юзера при регистрации), или в результате миграций. Можно включить третий источник — добавление данных от триггеров в базе. В любом случае, практически всегда можно разобраться: найти код, ответственный за добавление.

Эту особенность заметили разрушители, и я был свидетелем — если не сказать «потерпевшим» — последствий саботажа. На одном из тестовых стендов перестали проходить тесты, и я долго разбирался, почему данных не хватает. Первая гипотеза была связана с тем, что код случайно удалили, и я проверил историю релизов на полгода, выискивая, где оно потерялось. Оказалось, данные просто добавили руками.

Почему так лучше не делать? Скрипт о добавлении данных может затеряться, а на тестовых стендах может произойти чистка (запланированная или случайная). Могут добавиться новые стенды.

Файл миграции мог бы выступить в роли документации, поясняя, что данные статические. При необходимости раскатить сервис «с нуля» на новый стенд не нужно будет ничего делать руками дополнительно.

Когнитивное истощение: неидеоматичный код

С каким бы стеком вы ни работали, всегда ясно, как писать «правильно» — то есть как писать так,

  • как принято,

  • как пишут все,

  • как разработчики языков и фреймворков подразумевали написание кода.

Техно-анархисты зашли очень далеко и даже пишут книги с рекомендациями писать неидеоматично (Data-oriented programming). Автор рекомендует в статически типизированных языках, вроде Java и C#, использовать мапы вместо классов.

Вдохновившись этой книгой, пробовал писать в функциональном стиле flutter-приложение на Dart.

Dart is an object-oriented,  class-based,  garbage-collected language with C-style syntax. c. Wikipedia

Кто-то может возразить, что Dart поддерживает и функции высшего порядка, и иммутабельность, и стримы, и библиотеки с персистентными коллекциями есть. Но этого этого не хватает, и мне кажется, что попытка писать полностью функционально тормозит разработку на Dart раза в 3–4 в сравнении с дефолтным подходом (субъективно).

Также не стоит писать FP на Go или OOP на Clojure. Я зайду дальше и скажу, что на Clojure не стоит писать как на Haskell, используя моноиды и прочую теорию категорий. Clojure предлагает отличный набор инструментов для sequence abstraction, все остальное только мешает читать и поддерживать код.

Подрыв производительности

Код из под их пера подобен песочному замку, который не переживет очередного прилива.

Цитата неизвестного автора.

Маскировка проблем производительности через циклы

Прежде чем начать, сделаем небольшое отступление. Уверен, что каждый — даже самый маленький — понимает, что запрос к базе, развернутой на другой машине, значительно медленнее, чем запрос к хэшмапе (словарю) в том же потоке. Давайте оценим, насколько.

Данные получены из Графаны рабочего сервиса, в момент, когда я идентифицировал код агента хаоса. В конкретном примере поход в базу обходился в 2–4 миллисекунды.
Время запроса к мапе — 10–60 наносекунд. 10 из статьи «HashMap performance improvements in Java 8», 60 из личных замеров на своем стареньком маке.

Поскольку для человека наносекунды интуитивно не понятны, давайте переведем все на минуты. Вот что получается при грубом подсчете: если обращение к мапе — это 1 минута, то поход в базу — это примерно полгода. Разница колоссальная, как видите, и приверженцы техно-анархизма ее эксплуатируют.

Если обращение к мапе — это 1 минута, то поход в базу на другой машине — это примерно полгода.

Очень сложно «задедосить» сервис снаружи, но невероятно просто — изнутри. Надо только добавить запрос к базе в нескольких вложенных циклах. Вы спросите: как? ведь придется проходить code review! Я встречал неопытных анархистов, которые прямо вот так и писали:

for ...
    for ...
        for ...
            for ...
                db.pool.execute...

И этот код жил и хоронил сервер. Не преувеличиваю ни на цикл.

Выше — почерк неопытного агента анархии: его легко найти и устранить (я про код). Искусство же мастеров заключается в том, чтобы скрывать циклы, ловко манипулируя вниманием ревьюера. Давайте рассмотрим пример с виду безобидного кода, который пытается спрятать запрос к базе в трех вложенных циклах.

Первый файл — модельки данных:

// Названия продуктов в заказе
data class Order(val items: List)

// какая-то информация по рекламе
data class AdInfo(val adds: List)

В другом файле — работа с заказами. Главное, помещать функции из одного юз-кейса в разные файлы и перегружать внимание ревьюера разнообразием синтаксиса.

fun processOrder(data: Order) {
    // Тут какой-то отвлекающий код, чтобы усыпить внимание.
    
    // Анархист надеется, что цикл тут будет пропущен:
    repeat(data.items.size, ::process) 
    
    // Еще какой-то код, отвлекающий внимание.
}

fun process(index: Int) {
    val item = data.items[index]
    val (detailInfo, success) = getProductDetails(item)
    when(success) {
        true -> processDetail(detailInfo)

        // специально бросается general Exception,
        // чтобы ревьюер мог зацепиться и проглядел реальную проблему
        else -> throw Exception() 
    }
}

fun getProductDetails(item: String): Pair, Boolean> = TODO()
fun getAdInfo(key: String): List = TODO()

Третий файл — попытка спрятать цикл через создание интератора. Даже при беглом просмотре кода вы увидите for, но если закамуфлировать итератор за typealias, можно и проглядеть:

fun processDetail(details: List) {
    // какой-то еще код ...
    val detailProcessor = details.prepare()
    detailProcessor.process()
    // какой-то еще код ...
}

// Еще лучше было бы, если вынести код ниже в отдельный файл,
// чтобы спрятать информацию о листах и итераторах
typealias DetailsProcessor = ListIterator

fun List.prepare(): DetailsProcessor {
    return listIterator() 
} 

fun ListIterator.process() {
    while(this.hasNext()) {
        val adInfo = getAdInfo(this.next())
        adInfo.process()
    }
}

Обратите внимание, process не намекает на работу с базой, в отличие, например, от save. А ниже рекурсия опять скрывает цикл. Тут важно постоянно использовать разные конструкции: repeat, forEach, for, while, (0..size), onEach, рекурсию, do while — это создает дополнительную когнитивную нагрузку.

fun AdInfo.process() {
    // тут какой-то код ...
    if (addInfo.adds.count() == 0) return
    val info = adInfo.adds[0]
    saveAds(info)
    this.copy(adds = adInfo.adds.subList(1))
        .process()
}

fun saveAds(add: String): Boolean {
    db.pool.execute {
        // sql inside 3 for loops
    }
}

Ок, скажете вы, этот код абсурдный и никакое ревью не пройдет. Можно докопаться практически до каждой строчки. Дело в том, что кода будет больше, значительно больше. Там, где речь не идет о главном для анархиста (внутренняя DDoS-атака), код будет написан хорошо. По всему остальному любой, даже неопытный разработчик сможет защититься.

— Убери, пожалуйста, typealias, он прячет итератор и цикл.
— Спасибо за предложение, но я считаю, что так лучше читается, сразу по коду видно, где не просто итератор, а именно DetailsProcessor. Plain old domain specific design.

— Помести всю логику в одну функцию или хотя бы в один файл.
— А это у меня SRP (Solid), «high cohesion low coupling». Я предпочитаю не связывать логику обработки заказов и рекламы. Вдруг потом это будут разные сервисы, не стоит связывать их.

— Зачем используешь то range, то while, то итератор — это создает метальную нагрузку? Давай везде сделаем for.
— Да ладно, чего там сложного, разработчик должен знать базовые конструкции языка.

— Я вижу 3 вложенных цикла и поход в базу, сделай все без циклов, пожалуйста, они тут не нужны.
— Это преждевременная оптимизация. Давай, если начнутся проблемы, будем думать об оптимизациях. Фичу нужно срочно релизить, сейчас нет времени на рефакторинг.

Маскировка проблем производительности через триггеры

Апологеты техно-анархизма пользуются не только циклами в коде.

С точки зрения коэффициента затрат к выхлопу намного более вкусно выглядит тактика добавления триггеров на таблицы базы данных. Если этим пользоваться не часто, риск компрометации сводится к нулю.

Смекалистый разрушитель корпораций найдет самую большую и часто изменяемую таблицу и повесит триггер на нее. Опытный же мастер своего дела выберет такую таблицу, в которой данных еще немного, но расти они будут быстро.

Этот же прием подошел бы для усиления когнитивной нагрузки, если на триггеры вешать бизнес-логику.

Экспертная маскировка проблем с производительностью

Статей про то, как можно оптимизировать Postgres, очень много, например, вышедшая в совсем недавно «Оптимизация SQL запросов».

Не хочу пересказывать примеры из подобных статей, все что нужно для саботажа — делать обратное рекомендуемому в статьях.

Вместо заключения

Мотивы разрушителей кодовых баз мне не известны. Если это луддизм через обучение нейросетей плохим решениям, то я мог бы этому сочусвовать. О феноменальных возможностях нейросетей писал в своем тг-канале.

Понять можно и анархистов, борющихся с гнётом крупных корпораций.

Если же вы узнали в некоторых примерах себя и не являетесь членом организации, о которой шла речь, прошу не обижаться. Все совершают ошибки, и я не исключение. Хочется порекомендовать юмористическую статью The Grug Brained Developer с примерами «как надо».

Данная статья — попытка посмотреть на код с точки зрения нанесения ущерба кодовой базе — то есть «как не надо». Надеюсь, кому-то поможет писать более простой понятный код.

Кто-то стучится в дверь. Открою, а потом допишу последн…

© Habrahabr.ru