Skip to content

Instantly share code, notes, and snippets.

@glensand
Last active February 17, 2021 16:09
Show Gist options
  • Save glensand/8d11dbae855122122a2609dcc4849a44 to your computer and use it in GitHub Desktop.
Save glensand/8d11dbae855122122a2609dcc4849a44 to your computer and use it in GitHub Desktop.
Рефакторинг. Введение

Рефакторинг

Эта статья является скорее обзорной и содержит небольшие примеры, более подробно про рефакторинг можно прочитать в замечательной книге Мартина Фаулера "Рефакторинг: улучшение существующего кода". В ней собраны примеры типических рефакторингов с обоснованием их эффективности. Однако, она будет полезна для быстрого старта разработки на c++, и поможет предотвратить некоторые виды ошибок.

Цель

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

Микрорефакторинг

В общем случае микрорефакторинг не затрагивает архитектуру, например, разделение методов на несколько частей, посмотрим на метод ещё одной реализации класса вектор:

// ...
template<typename T>
void vector<T>::push_back(const T& value)
{
	if (data == nullptr) {
		data = new T[1];
		data[0] = value;
		size++;
	} else {
		auto* tmp = new T[size + 1];
		memcpy(tmp, data, sizeof(T) * size);
		tmp[size] = value;
		delete[] data;

		data = tmp;
		size++;
	}
}

Этот метод можно разделить минимум на два:

// ...
template<typename T>
void vector<T>::enlarge() {
	auto* tempHolder = new T[size + 1];
	memcpy(tempHolder, data, sizeof(T) * size);
	delete data;
	data = tempHolder;
}

template<typename T>
void vector<T>::push_back(const T& value) {
	enlarge();
 	data[size] = value;
	++size;
}

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

enum class ComplicatedState : std::size_t {
 	On,
 	Off,
 	Halt,
  
 	Execution,
}
void foo(ComplicatedState state, ComplicatedState filteredState, ComplicatedState internalState) {
	if(state == ComplicatedState::On && filteredState == ComplicatedState::Execute
    	|| internalState == ComplicatedState::Halt && state == ComplicatedState::Execution)	
		return;
  // Some computation 
}

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

bool isStateSuitable(ComplicatedState state, ComplicatedState filteredState, ComplicatedState internalState) {
	using ComplicatedState;
	return state ==On && filteredState == Execute || internalState == Halt && state == Execution;
}

void foo(ComplicatedState state, ComplicatedState filteredState, ComplicatedState internalState) {
	if(!isStateSuitable(state, filteredState, iternalState)) return;
// ... do some work 
}

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

Маленькие хитрости

Итак, в микрорефакторинге стоит придерживаться пары базовых принципов:

  • Не более двух уровней вложенного ветвления и циклов. Внутренние циклы и операторы ветвления нужно выделять в отдельный метод
  • Придерживаться принципа раннего выброса (как в примере выше), если какое-то условие для выполнения метода не выполняется, то нужно выполнить возврат при помощи оператора return;
  • Контролировать размер метода (не более 1 экрана или ~100 строк кода)
  • Создавать маленькие удобные классы, с небольшим количеством публичных методов (~5)
  • Не допускать глубоких иерархий наследования (стараться не наследовать функционал)
  • Добавлять const, final, override, noexcept, constexpr квалификаторы там где это нужно
  • Выделять интерфейсы, даже если пока существует только одна реализация

Макрорефакторинг

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

  • Тестирование Legacy. Да, старый код для начала придется покрыть тестами. Никто не будет спорить что все то, что ранее работало должно продолжать работать. Какую бы сильную нелюбовь я ни испытавал по отношению к дилетантской мантре: "Работает не лезь!", доля правды в ней есть, код должен работать! Потому, перед тем как приступить к написанию нового кода, нужно протестировать старый... Тесты, для старого кода должны запускаться без изменений и для нового, а значит нам нужен интерфейс. Используем паттерн адаптер, выделяем интерфейс (за основу можно взять старый код или что-то между старым и новым кодом), делаем обертку для Legacy, пишем достаточное количество тестов с хорошим покрытием.
  • Думаем над тем какой интерфейс требуется для нового модуля. Он не обязан совпадать с Legacy кодом и с интерфейсом адаптера. Но должен удовлетворять запросам пользователя - целям рефакторинга, например быть достаточно гибким для скорого расширения функционала, либо быть консистентным с остальными модулями библиотеки (классами модуля). После того как интерфейс сформирован, можно переходить к следующему пункту
  • Реализация. Универсального гайдлайна для реализаций нет, иначе бы все программы писались по шаблону при помощи подстановки правильных слов :). Удовлетворяем требованиям бизнеса и переходим к следующему пункту.
  • Тестирование нового кода. Пишем обертку добиваемся работоспособности.
  • Внедрение нового кода: внедрять можно как с использованием адаптера (если требуется совместная работа Legacy и новой реализации), так и с полной заменой с использованием исходного интерфейса. Для начала подойдет примитивный односвзязный список, написаный на языке Си (на его место может быть любой контейнер, да вообще что угодно):
struct _list_node;
typedef struct _list_node *list;
typedef struct _list_node *list_node;

list* create_list();
void* get_data(list_node);
void insert(void* data, list);
void remove(void* data, list);
list_node find(void* data, list);

Если используется обобщенный в Си-стиле связный список имеет смысл делать адаптер шаблоном класса, в таком случае тесты основываются на статическом полиморфизме.

Что если усложнить жизнь и сказать что вместо void* используется int? Но в новом коде мы не хотим ограничиваться только целыми типами, работа которую стоит проделать чтобы написать шаблон связного списка и список целых чисел соизмерима, посему новая реализация будет в точности повторять реализацию из примера выше. Дело за малым, используя адаптер сделать новый и старый код взаимозаменяемым, выделяем интерфейс, добавляем по одной реализации для Legacy и нового класса.

Упражнение

Провести рефакторинг декартова дерева (дерамида) За основу необходимо взять пример номер 2 из пункта макрорефакторинг, исходный код на Си находится в архиве materials, там же можно найти Uml диаграммы из этого урока.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment