Pull to refresh

Ветвления. Что с ними можно сделать

Reading time11 min
Views8.4K
Мой прошлый пост вызвал огромный резонанс. Комментариев было не много, но я получил множество писем, а некоторые читатели выступили даже с открытыми заявлениями (там, правда, преобладают наезды на меня лично и на хабр в целом, но есть и мысли по существу вопроса). Поэтому я решил продолжить писать в жанре «мои мысли по поводу вопросов известной компании». Этим постом я постараюсь решить две задачи: (i) ответить на вопросы и возражения читателей предыдущего поста и (ii) толкнуть в некотором смысле философскую мысль о безIFовом программировании. Букв получилось довольно много, но те, кому интересно только что-то одно из поста, могут пропустить половину.

И ещё: этот топик (как и прошлый) — не наезд ни на кого. Просто интересно порассуждать об интересных вопросах. Здесь нет подтекста, намёка, вызова. Параноиков и сторонников теорий заговоров попрошу расслабиться.

В этот раз хотел бы взглянуть на вопрос 4.

Вот его код. Немного причёсанный так, чтобы он собирался

#include <string>
#include <stdexcept>
#include <iostream>

class CodeGenerator
{
public:
    enum Lang {JAVA, C_PLUS_PLUS, PHP};
    CodeGenerator(Lang language) { _language=language; }
    std::string generateCode()
    {
        switch(_language) {
        case JAVA:
            return std::string("Java: System.out.println(\"Hello world!\");");
        case C_PLUS_PLUS:
            return std::string("C++: std::cout << \"Hello world!\";");
        }
        throw new std::logic_error("Bad language");
    }
    std::string someCodeRelatedThing() // used in generateCode()
    {
        switch(_language) {
        case JAVA:
            return std::string("http://www.java.com/");
        case C_PLUS_PLUS:
            return std::string("http://www.cplusplus.com/doc/tutorial/");
        }
        throw new std::logic_error("Bad language");
    }

private:
    Lang _language;
};

int main() {
    CodeGenerator cg(CodeGenerator::JAVA);
    std::cout << cg.generateCode()
              << "\tMore info: "
              << cg.someCodeRelatedThing()
              << std::endl;
    CodeGenerator ec(CodeGenerator::PHP);
    try {
        ec.generateCode();
    } catch (std::logic_error * e) {
        std::cout << "ERROR: " << e->what() << std::endl;
    }
    return 0;
}

Давайте наметим, от чего тут хочется избавиться:
  • Хочется не сваливать все языки в одну кучу. Если их разнести, то их станет проще поддерживать, обозревать глазом, тестировать.
  • Хочется избавиться от множественных switch-ей. Они загромождают код и за ними за всеми надо следить при добавлении новых языков. Это хлопотно. Кроме того, эти switch-и каждый раз выполняются и пользователь класса не может с этим ничего поделать.
  • Хочется избавиться от бомбы замедленного действия в лице _language. Эта замечательная переменная устанавливается в одном месте, а используется (и вызывает исключения!) в другом (вернее в других). Это безусловно не облегчает отладку и поиск причин отказов.
  • Хочется отделить интерфейс от реализации чтобы эти две вещи не сдерживали развитие друг друга.
  • Хочется придти к такому дизайну, который будет поощрять программиста реализовывать раннюю диагностику ошибок, однократную «расшифровку» значения _language. В общем, действовать разумно. Существующее решение поощряет прямо противоположное поведение.
Будем двигаться последовательно. Букв получилось много, но все они просты и, надеюсь, не потребуют чрезмерных мозговых усилий. Наливаем себе своего любимого напитка и приступаем.

Разделяем и властвуем


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

#include <string>
#include <stdexcept>
#include <iostream>

class CodeGeneratorJavaProcessor {
public:
    std::string code() {
        return std::string("Java: System.out.println(\"Hello world!\");");
    }
    std::string thing() {
        return std::string("http://www.java.com/");
    }
};

class CodeGeneratorCppProcessor {
public:
    std::string code() {
        return std::string("C++: std::cout << \"Hello world!\";");
    }
    std::string thing() {
        return std::string("http://www.cplusplus.com/doc/tutorial/");
    }
};

class CodeGenerator
{
public:
    enum Lang {JAVA, C_PLUS_PLUS, PHP};
    CodeGenerator(Lang language) { _language=language; }
    std::string generateCode()
    {
        switch(_language) {
        case JAVA:
            return CodeGeneratorJavaProcessor().code();
        case C_PLUS_PLUS:
            return CodeGeneratorCppProcessor().code();
        }
        throw new std::logic_error("Bad language");
    }
    std::string someCodeRelatedThing()
    {
        switch(_language) {
        case JAVA:
            return CodeGeneratorJavaProcessor().thing();
        case C_PLUS_PLUS:
            return CodeGeneratorCppProcessor().thing();
        }
        throw new std::logic_error("Bad language");
    }

private:
    Lang _language;
};

int main() {
    CodeGenerator cg(CodeGenerator::JAVA);
    std::cout << cg.generateCode()
              << "\tMore info: "
              << cg.someCodeRelatedThing()
              << std::endl;
    CodeGenerator ec(CodeGenerator::PHP);
    try {
        ec.generateCode();
    } catch (std::logic_error * e) {
        std::cout << "ERROR: " << e->what() << std::endl;
    }
    return 0;
}

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

Единый интерфейс и отказ о множественных switch-ей


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

#include <string>
#include <stdexcept>
#include <iostream>

class CodeGeneratorAbstractProcessor {
public:
    virtual std::string code() = 0;
    virtual std::string thing() = 0;
    virtual ~CodeGeneratorAbstractProcessor() {}
};

class CodeGeneratorJavaProcessor : public CodeGeneratorAbstractProcessor {
public:
    std::string code() {
        return std::string("Java: System.out.println(\"Hello world!\");");
    }
    std::string thing() {
        return std::string("http://www.java.com/");
    }
};

class CodeGeneratorCppProcessor : public CodeGeneratorAbstractProcessor {
public:
    std::string code() {
        return std::string("C++: std::cout << \"Hello world!\";");
    }
    std::string thing() {
        return std::string("http://www.cplusplus.com/doc/tutorial/");
    }
};

class CodeGeneratorBadProcessor : public CodeGeneratorAbstractProcessor {
public:
    std::string code() {
        throw new std::logic_error("Bad language");
        return std::string();
    }
    std::string thing() {
        throw new std::logic_error("Bad language");
        return std::string();
    }
};

class CodeGenerator
{
public:
    enum Lang {JAVA, C_PLUS_PLUS, PHP};
    CodeGenerator(Lang language) : processor(0) {
        switch(language) {
        case JAVA:
            processor = new CodeGeneratorJavaProcessor();
            break;
        case C_PLUS_PLUS:
            processor = new CodeGeneratorCppProcessor();
            break;
        case PHP:
            processor = new CodeGeneratorBadProcessor();
            break;
        }
    }
    ~CodeGenerator() { delete processor; }
    std::string generateCode() { return processor->code(); }
    std::string someCodeRelatedThing() { return processor->thing(); }

private:
    CodeGeneratorAbstractProcessor * processor;
};

int main() {
    CodeGenerator cg(CodeGenerator::JAVA);
    std::cout << cg.generateCode()
              << "\tMore info: "
              << cg.someCodeRelatedThing()
              << std::endl;
    CodeGenerator ec(CodeGenerator::PHP);
    try {
        ec.generateCode();
    } catch (std::logic_error * e) {
        std::cout << "ERROR: " << e->what() << std::endl;
    }
    return 0;
}

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

А не поменять ли нам взгляд на вещи?


Давайте рассмотрим, что делает теперь CodeGenerator. Всего две вещи:
  • Преобразует enum в объект в конструкторе
  • Предоставляет обёртку над объектом, который и выполняет всю работу
Нужен ли нам вообще такой класс? Оставим этот вопрос пока открытым и посмотрим, как предполагается использовать этот класс. Очевидно, как-то так:

void i_like_to_convert_enum_to_lang(CodeGenerator::Lang lang_code) {
    CodeGenerator cg(lang_code);
    std::cout << cg.generateCode()
              << "\tMore info: "
              << cg.someCodeRelatedThing()
              << std::endl;
}

То есть, предполагается, что мы по жизни тащим enum, и преобразуем его в алгоритм там, где нам вдруг это понадобится. Со всеми накладными расходами: создание/удаление объектов, проверка корректности нашего enum-a, обработка исключений… Может быть разумней было бы один раз преобразовать enum в объект и дальше жить с ним? Это решило бы множество проблем и дало бы множество преимуществ. Скажем, единый сквозной объект мог бы собирать статистку о своей работе, что-то кэшировать… Как вам такое решение:

void i_like_to_use_generators(CodeGeneratorAbstractProcessor * cg) {
    std::cout << cg->code()
              << "\tMore info: "
              << cg->thing()
              << std::endl;
}

Здесь мы действительно отказались от CodeGenerator.

Конечно, такой решительный шаг не обошёлся без изменения интерфейса, но за-то сколько преимуществ мы получили!

Кстати (возвращаясь к обсуждению предыдущей заметки), обратите внимание, как только мы отказались от бомбы _language и похожей не её бомбы processor, операция создания и удаления генератора на столько приблизились друг к другу, что необходимость полиморфного удаления объектов пропала.

И сейчас самое время ввернуть лирическое отступление.

<Лирическое отступление>

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

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

Часто это делается так:

#include <iostream>
#include <unistd.h>

int main(int argc, char *argv[]) {
    // анализируем параметры (первое ветвление)
    bool debug(false);
    int r;
    while ((r = getopt(argc, argv, "d")) != -1) {
        switch ( r ){
        case 'd':
            debug = true;
            break;
        }
    }
    // в основном теле каждый раз проверяем,
    // не включена ли отладка
    // (размножившиеся ветвления)
    if (debug) {
        std::cout << "Some debugging" << std::endl;
    }
    return 0;
}

Примеры такого кода можно найти в исходниках cdrtools, Python'a, git, cmake, m4 (переменная debug), firefox (debug_mode), mpalyer/mencoder (поля типа debug, b_debug в различных структурах), x11vnc (debug/crash_debug/debug_pointer)… посмотрите исходники вашей любимой программы и вы найдёте дополнительные иллюстрации.

Проблема в том, что первый if преобразует одно логическое значение в другое. А потом, в основном коде, это второе логическое значение каждый раз интерпретируется.

Это конечно не приводит к понижению производительности (даже наоборот! это очень хороший в плане производительности подход), но это загромождает код и делает его поддержку очень сложной. Во всей этой куче кода легко сделать ошибку. Эту кучу кода трудно менять.

Но, к счастью, есть и другое решение. Типа такого:

#include <iostream>
#include <unistd.h>

class noout : public std::ostream {};
noout& operator<< (noout& out, const char* s) { return out; }

int main(int argc, char *argv[]) {
    // анализируем входные параметры и превращаем их
    // сразу в поведение
    noout e;
    std::ostream *debug(&e);
    int r;
    while ((r = getopt(argc, argv, "d")) != -1) {
        switch ( r ){
        case 'd':
            debug = &std::cout;
            break;
        }
    }
    // больше не надо ветвлений
    *debug << "Some debugging" << std::endl;
    return 0;
}

Здесь преобразование производится сразу в объект, определяющий, что делать с отладочными сообщениями.

В основном теле программы теперь ветвления не нужны.

Больше того, теперь очень легко развивать этот код. На пример, мы можем добавить новый поток для направления отладочной информации.

#include <iostream>
#include <unistd.h>

class noout : public std::ostream {};
noout& operator<< (noout& out, const char* s) { return out; }

int main(int argc, char *argv[]) {
    // с лёгкостью добавляем новый
    // поток для отправки отладочной информации
    noout e;
    std::ostream *debug(&e);
    int r;
    while ((r = getopt(argc, argv, "de")) != -1) {
        switch ( r ){
        case 'd':
            debug = &std::cout;
            break;
        case 'e':
            debug = &std::cerr;
            break;
        }
    }
    // в самой программе как не было необходимости
    // в ветвлениях, так её и нет
    *debug << "Some debugging" << std::endl;
    return 0;
}

Конечно, эта реализация лишь иллюстрирует подход. Конечно я забыл добавить

noout& operator<< (noout& out, const signed char* s);
noout& operator<< (noout& out, const unsigned char* s);
...

и ещё много-много чего. Конечно, было бы правильней использовать полноценное средство для протоколирования. Это всё понятно. Но надеюсь, что саму идею я проиллюстрировал.

И, обратите внимание, и тут объекты живут полиморфной жизнью, но вот полиморфное удаление нам пока не понадобилось (это снова возвращаясь к предыдущей заметке).

</Лирическое отступление>

Отделим интерфейс от реализации


Давайте немного переделаем класс CodeGeneratorAbstractProcessor, заодно продемонстрировав концепцию NVI. Смотрите, как славно отделилась реализация от интерфейса:

#include <string>
#include <stdexcept>
#include <iostream>

class CodeGeneratorAbstractProcessor {
public:
    std::string generateCode() { return code(); }
    std::string someCodeRelatedThing() { return thing(); }
protected:
    ~CodeGeneratorAbstractProcessor() {}
private:
    virtual std::string code() = 0;
    virtual std::string thing() = 0;
};

class CodeGeneratorJavaProcessor : public CodeGeneratorAbstractProcessor {
public:
    std::string code() {
        return std::string("Java: System.out.println(\"Hello world!\");");
    }
    std::string thing() {
        return std::string("http://www.java.com/");
    }
};

class CodeGeneratorCppProcessor : public CodeGeneratorAbstractProcessor {
public:
    std::string code() {
        return std::string("C++: std::cout << \"Hello world!\";");
    }
    std::string thing() {
        return std::string("http://www.cplusplus.com/doc/tutorial/");
    }
};

class CodeGeneratorBadProcessor : public CodeGeneratorAbstractProcessor {
public:
    std::string code() {
        throw new std::logic_error("Bad language");
        return std::string();
    }
    std::string thing() {
        throw new std::logic_error("Bad language");
        return std::string();
    }
};

void i_like_to_use_generators(CodeGeneratorAbstractProcessor * cg) {
    std::cout << cg->generateCode()
              << "\tMore info: "
              << cg->someCodeRelatedThing()
              << std::endl;
}

int main() {
    CodeGeneratorJavaProcessor java;
    i_like_to_use_generators(&java);
    return 0;
}

Открытые методы реализуют интерфейс. Закрытые и виртуальные методы служат для настройки поведения класса. Этими двумя вещами теперь можно легко и непринуждённо жонглировать. Делая и то и другое оптимальным. На пример так:

#include <string>
#include <stdexcept>
#include <iostream>

class CodeGeneratorAbstractProcessor {
public:
    std::string generateCode() { return lang() + ": " + code(); }
    std::string someCodeRelatedThing() { return thing(); }
protected:
    ~CodeGeneratorAbstractProcessor() {}
private:
    virtual std::string lang() = 0;
    virtual std::string code() = 0;
    virtual std::string thing() = 0;
};

class CodeGeneratorJavaProcessor : public CodeGeneratorAbstractProcessor {
public:
    std::string lang() {
        return std::string("Java");
    }
    std::string code() {
        return std::string("System.out.println(\"Hello world!\");");
    }
    std::string thing() {
        return std::string("http://www.java.com/");
    }
};

class CodeGeneratorCppProcessor : public CodeGeneratorAbstractProcessor {
public:
    std::string lang() {
        return std::string("C++");
    }
    std::string code() {
        return std::string("std::cout << \"Hello world!\";");
    }
    std::string thing() {
        return std::string("http://www.cplusplus.com/doc/tutorial/");
    }
};

class CodeGeneratorBadProcessor : public CodeGeneratorAbstractProcessor {
public:
    std::string lang() {
        throw new std::logic_error("Bad language");
        return std::string("???");
    }
    std::string code() {
        throw new std::logic_error("Bad language");
        return std::string();
    }
    std::string thing() {
        throw new std::logic_error("Bad language");
        return std::string();
    }
};

void i_like_to_use_generators(CodeGeneratorAbstractProcessor * cg) {
    std::cout << cg->generateCode()
              << "\tMore info: "
              << cg->someCodeRelatedThing()
              << std::endl;
}

int main() {
    CodeGeneratorJavaProcessor java;
    i_like_to_use_generators(&java);
    return 0;
}

И интерфейс для пользователя и «интерфейс» для разработчика теперь можно сделать компактным не противоречивым, удобным, логичным. И сам класс теперь диктует всем определённую дисциплину и структуру.

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

А на картинке изображён дольмен, который не имеет никакого отношения к статье. Просто именно им захотелось оформить статью на эту тему.
Tags:
Hubs:
Total votes 60: ↑49 and ↓11+38
Comments35

Articles