Выделение изменений в коде

2008-01-14

Часто код бывает не очень читаемым. Особенно неприятно, когда простой по своей сути код выглядит как огромное количество проверок, специальных случаев и т. д. Эффект, конечно, начинает действовать, когда такого кода не одна процедура, а целые классы и модули. Тогда оказывается, что, например, вместо 500 строк кода модуль занимает 2000. Из этих 2000 строк только небольшое количество присутствовало в первых версиях модуля. Вначале код лишь кратко описывал суть алгоритмов. Потом же в него добавлялись новые возможности, параметры, исправления багов и т. д. Это, наверное, одна из главных причин обилия специальных случаев в коде.

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

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

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

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

Для пояснения способа возьмём пример. В этом примере для улучшения читаемости используем 3 средства:

  • Замена императивных циклов и проверок на списковые операции и определения списков (list comprehensions или аналогичные конструкции)
  • Выделение второстепенного кода во вложенные процедуры
  • Явное выделение пред- и пост-условий

Примером послужит такой императивный метод Logger.callHandlers из модуля logging из стандартной библиотеки языка Python:

def callHandlers(self, record):
    c = self
    found = 0
    while c:
        for hdlr in c.handlers:
            found = found + 1
            if record.levelno >= hdlr.level:
                hdlr.handle(record)
        if not c.propagate:
            c = None    #break out
        else:
            c = c.parent
    if (found == 0) and raiseExceptions and not self.manager.emittedNoHandlerWarning:
        sys.stderr.write("No handlers could be found for logger"
                         " \"%s\"\n" % self.name)
        self.manager.emittedNoHandlerWarning = 1

Вначале он, наверное, выглядел примерно так:

def callHandlers(self, record):
    c = self
    while c:
        for hdlr in c.handlers:
            if record.levelno >= hdlr.level:
                hdlr.handle(record)
        c = c.parent

Довольно прозрачно, не правда ли? Но потом он оброс различными проверками, условиями и т. д. Посмотрим на него и попробуем сделать метод более читаемым при помощи некоторых приёмов из области функционального программирования.

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

Тот же код, в котором предки получаются функционально:

def callHandlers(self, record):
    def affectedBy(log):
        if log is None:
            return []
        else:
            return [log] + affectedBy(log.parent)
    for log in affectedBy(self):
        for hdlr in log.handlers:
            if record.levelno >= hdlr.level:
                hdlr.handle(record)

Кода стало немного больше, а плюсов, вроде бы, добавилось немного. Это не совсем так, ведь теперь мы можем определять затронутые обработкой логгеры, не меняя основной код метода Logger.callHandlers. Добавим ту часть первоначального кода, которая блокировала распространение обработки сообщения вверх по иерархии логгеров. Для этого нужно изменить только функцию получения затрагиваемых логгеров:

def affectedBy(log):
    if log is None:
        return []
    elif not log.propagate:
        return [log]
    else:
        return [log] + affectedBy(log.parent)

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

def callHandlers(self, record):
    def affectedBy(log): ...
    handlers = sum(log.handlers for log in affectedBy(self), [])
    found = len(handlers) > 0
    for hdlr in handlers:
        if record.levelno >= hdlr.level:
            hdlr.handle(record)
    if not found and raiseExceptions and not self.manager.emittedNoHandlerWarning:
        sys.stderr.write("No handlers could be found for logger"
                         " \"%s\"\n" % self.name)
        self.manager.emittedNoHandlerWarning = 1

Ну и наконец, хвост метода про выдачу предупреждения явно мешает чтению и отвлекает от основной сути. Обернём его в процедуру, вызвав его как своего рода пост-условие для метода Logger.callHandlers. Полный улучшенный аналог первоначального кода выглядит так:

def callHandlers(self, record):
    def affectedBy(log):
        if log is None:
            return []
        elif not log.propagate:
            return [log]
        else:
            return [log] + affectedBy(log.parent)
    def checkEmittedNoHandlerWarning(found):
        if not found and raiseExceptions and not self.manager.emittedNoHandlerWarning:
            sys.stderr.write("No handlers could be found for logger"
                             " \"%s\"\n" % self.name)
            self.manager.emittedNoHandlerWarning = 1
    handlers = sum(log.handlers for log in affectedBy(self), [])
    for hdlr in handlers:
        if record.levelno >= hdlr.level:
            hdlr.handle(record)
    checkEmittedNoHandlerWarning(len(handlers) > 0)

Если скрыть тела вложенных в блок метода функций, он выглядит так:

def callHandlers(self, record):
    def affectedBy(log): ...
    def checkEmittedNoHandlerWarning(found): ...
    handlers = sum(log.handlers for log in affectedBy(self), [])
    for hdlr in handlers:
        if record.levelno >= hdlr.level:
            hdlr.handle(record)
    checkEmittedNoHandlerWarning(len(handlers) > 0)

Заметим, что метод по-прежнему императивный, т. к. его назначение — вызвать другие императивные методы Handler.handle. И к тому же проверка выдачи предупреждения использует состояние самого объекта Logger для хранения флага. Но с помощью нескольких приёмов FP код метода Logger.callHandlers стал немного чище. Здесь слово «чище» использовано в двух значениях:

  • Стало меньше изменяемого состояния и присваиваний (чище в смысле ФП)
  • Код стал более читаемым, вторичные аспекты меньше заслоняют первичные