Мой прошлый пост вызвал огромный резонанс. Комментариев было не много, но я получил множество писем, а некоторые читатели выступили даже с открытыми заявлениями (там, правда, преобладают наезды на меня лично и на хабр в целом, но есть и мысли по существу вопроса). Поэтому я решил продолжить писать в жанре «мои мысли по поводу вопросов известной компании». Этим постом я постараюсь решить две задачи: (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; }
И интерфейс для пользователя и «интерфейс» для разработчика теперь можно сделать компактным не противоречивым, удобным, логичным. И сам класс теперь диктует всем определённую дисциплину и структуру.
Таким образом, все заявленные в начале статьи пожелания мы выполнили. Пора бы уже остановится. Снимаю шляпу перед всеми, кто дочитал до конца. Спасибо.
А на картинке изображён дольмен, который не имеет никакого отношения к статье. Просто именно им захотелось оформить статью на эту тему.