О хорошей и плохой архитектуре или как расширять функции кода
Всем добрый день.
Был у меня заказ на несколько статей по IT-тематике, но в последний момент, публиковаться передумали. Статьи оплачены, почти дописаны, чего добру пропадать. Размещаю здесь, про пони в статье почти ничего нет, кроме текстовых констант, но зато тематике блога подходит.
Надеюсь, кому-то будет полезным. Два важных уточнения:
1) Материал статьи предназначен для людей, имеющих хотя бы небольшой опыт в объектном программировании — для новичков, тематика будет не вполне очевидна.
2) Код примеров разработан на ObjectPascal, так было нужно.
При чтении, по сравнению с С++ ориентированными языками, достаточно запомнить, что
1) begin и end эаменяют собой фигурные скобки
2) функции, возвращающие void — это procedure, остальные это function
3) конструктор и деструктор называются явно constructor и destructor, и также явно вызываются вместо использования new и delete
4) слово uses — указывает используемые модули, вроде привычных include или using.
5) переменные объявляют не где попало, а в начале блока через ключевое слово var
Остальное должно быть понятно интуитивно. Ну еще названия классов исторически начинаются с буквы T, это не правило, но принятый стиль.
Сам текст статьи.
Представим, что вы — важный разработчик с частной практикой. В один прекрасный день вам заказали класс на ObjectPascal для мода понной игры — класс всего-то должен накапливать переданные ему строки со списком драгоценных камней, а по вызову деструктора — отправлять весь список в статический метод другого класса, указывая имя пони 'rarity' в качестве получателя. Вы открыли любимый Delphi IDE и написали класс.
Первый вариант класса
Заказчик закинул класс в свой мод, получил результат и очень доволен. Денежки вам перевели, хэппи энд. Что было дальше?
А дальше, через пару дней, заказчик пишет, что всё отлично, мод работает, драгоценности к пони поступают — но нужно расширить функции. Теперь строки должны отправлять не единым пакетом в конце работы класса, а по мере накопления заданного количества, при этом, к имени пони должен добавляться числовой суффикс с номером пакета, начиная с нуля, а при вызове деструктора, отправить то, что еще не отправлено. За заказ обещают доплатить, так что снова открываем окошки IDE и переписываем класс.
Вторая версия с новой логикой работы
Заказчик оплатил, код отправлен в репозиторий. Всё хорошо?
Нет.
Почему так получилось?
1) Код сильно усложнился, а проверить его невозможно — у нас нет доступа к исходникам с классом TPony, соответственно, отправить класс в тестовую среду мы не можем.
2) Формирование имени пони 'rarity'+IntToStr(num) дублируется в двух местах.
3) Цикл отправки for s in list do дублируется в двух местах
4) Мы заменили публичный конструктор, и теперь везде, где был задействован старый класс, его не смогут скомпилировать в проекте.
5) Мы молча убрали старое поведение класса, а ведь никто не обещал, что рано или поздно его не попросят вернуть обратно или сделать опцией.
Иными словами, мы создали плохую архитектуру.
И что с этим делать? Очевидно, что у нас есть два вида поведения класса — полная отправка единым пакетом и отправка по мере накопления заданного количества, причем первая не имеет параметров, а вторая — имеет. Плюс нужно вынести эту логику за пределы юнита, чтобы была возможность её протестировать без привязки к чужому классу TPony.
Итак, это паттерн «Стратегия» — мы выделяем отдельный класс, отвечающий за логику накопления и отправки данных, и реализуем для него два наследника, один из которых работает по старой схеме, а другой создается с заданным размером пакета и отправляет по мере накопления. В коде основного класса будем создавать конкретную стратегию и передавать ей параметры, а также обратную функцию для отправки накопленных данных.
Класс абстрактной стратегии
Сам по себе класс умеет отправлять в переданный ему метод накопленные строки и делает это в деструкторе.
Чтобы класс заработал, нужно в потомках реализовать методы AddGem и GetPonyName, которые отвечают за формирование имени пони и добавления строк.
Сделаем это:
Реализация старой стратегии
В ней нет ничего нового — просто добавляем строки в список и в качестве имени пони, используем предопределенную строку.
Реализация стратегии с размером блока
А вот тут уже новая механика — добавляет размер блока, счетчик и в методе добавления, мы контролируем размер и производим отправку/очистку по достижению количества, плюс в коде метода имени, добавляется число к строке имени. Обращаем внимание, что всё это вынесено в отдельный класс без зависимостей — теперь мы сможем проверить эту механику в тестовой среде.
Теперь переделаем наш заказанный класс на использование стратегий:
Класс с использованием стратегий
Во-первых, мы оставили старый и добавили новый конструктор. В старом создается старая стратегия, а в новом — новая. Далее строки передаются в объект стратегии, которому при создании был указан метод обратного вызова, внутри которого и идет отправка в целевой класс TPony. Всё это не засоряет основной код и открыто к добавлению новых вариантов стратегий, например, если заказчик пожелает отдельно отправлять строки с четными и нечетными номерами.
Вроде всё хорошо, но давайте еще реализуем тесты. У нас есть такая возможность, поскольку классы стратегий отвязаны от основного модуля.
Тесты для старой и новой стратегий
Как и ожидалось, тесты успешно выполняются и мы теперь спокойны за работу класса. Архитектура кода стала намного лучше.
Что почитать, дабы больше узнать о том, как организовывать свои классы:
1) Конкретно по данному паттерну — https://ru.wikipedia.org/wiki/Стратегия_(шаблон проектирования)
2) В целом по проектированию — если начать с классики, то проверенная временем «Банда четырех» с паттернами проектирования: ru.wikipedia.org/wiki/Design_Patterns
3) Если же хочется именно практических советов, то Мартин Фаулер «Рефакторинг: улучшение проекта существующего кода». Там затронуты и другие проблемы, вроде дублирования кода и избавления от зависимостей.
Спасибо, всем хорошего кода.
Все, кто дочитал до сюда — есть еще вторая статья, но она более сложная и к ней еще не готовы примеры кода. Возможно, попозже, как будет время.
Был у меня заказ на несколько статей по IT-тематике, но в последний момент, публиковаться передумали. Статьи оплачены, почти дописаны, чего добру пропадать. Размещаю здесь, про пони в статье почти ничего нет, кроме текстовых констант, но зато тематике блога подходит.
Надеюсь, кому-то будет полезным. Два важных уточнения:
1) Материал статьи предназначен для людей, имеющих хотя бы небольшой опыт в объектном программировании — для новичков, тематика будет не вполне очевидна.
2) Код примеров разработан на ObjectPascal, так было нужно.
При чтении, по сравнению с С++ ориентированными языками, достаточно запомнить, что
1) begin и end эаменяют собой фигурные скобки
2) функции, возвращающие void — это procedure, остальные это function
3) конструктор и деструктор называются явно constructor и destructor, и также явно вызываются вместо использования new и delete
4) слово uses — указывает используемые модули, вроде привычных include или using.
5) переменные объявляют не где попало, а в начале блока через ключевое слово var
Остальное должно быть понятно интуитивно. Ну еще названия классов исторически начинаются с буквы T, это не правило, но принятый стиль.
Сам текст статьи.
О хорошей и плохой архитектуре или как расширять функции кода
Представим, что вы — важный разработчик с частной практикой. В один прекрасный день вам заказали класс на ObjectPascal для мода понной игры — класс всего-то должен накапливать переданные ему строки со списком драгоценных камней, а по вызову деструктора — отправлять весь список в статический метод другого класса, указывая имя пони 'rarity' в качестве получателя. Вы открыли любимый Delphi IDE и написали класс.
Первый вариант класса
unit GemCollector;
interface
uses Classes ;
type
TGemCollector = class
private
list:TStringList ;
public
constructor Create() ;
destructor Destroy ; override ;
procedure AddGem(const gem:string) ;
end;
implementation
uses Pony ;
{ TGemCollector }
procedure TGemCollector.AddGem(const gem: string);
begin
list.Add(gem) ;
end;
constructor TGemCollector.Create;
begin
list:=TStringList.Create() ;
end;
destructor TGemCollector.Destroy;
var s:string ;
begin
for s in list do
TPony.SendGem('rarity',s) ;
list.Free ;
inherited Destroy;
end;
end.
Заказчик закинул класс в свой мод, получил результат и очень доволен. Денежки вам перевели, хэппи энд. Что было дальше?
А дальше, через пару дней, заказчик пишет, что всё отлично, мод работает, драгоценности к пони поступают — но нужно расширить функции. Теперь строки должны отправлять не единым пакетом в конце работы класса, а по мере накопления заданного количества, при этом, к имени пони должен добавляться числовой суффикс с номером пакета, начиная с нуля, а при вызове деструктора, отправить то, что еще не отправлено. За заказ обещают доплатить, так что снова открываем окошки IDE и переписываем класс.
Вторая версия с новой логикой работы
unit GemCollector;
interface
uses Classes ;
type
TGemCollector = class
private
list:TStringList ;
blocksize:Integer ;
num:Integer ;
public
constructor Create(Ablocksize:Integer) ;
destructor Destroy ; override ;
procedure AddGem(const gem:string) ;
end;
implementation
uses Pony, SysUtils ;
{ TGemCollector }
procedure TGemCollector.AddGem(const gem: string);
var s:string ;
begin
list.Add(gem) ;
if list.Count>=blocksize then begin
for s in list do
TPony.SendGem('rarity'+IntToStr(num),s) ;
list.Clear() ;
Inc(num) ;
end;
end;
constructor TGemCollector.Create(Ablocksize:Integer);
begin
list:=TStringList.Create() ;
blocksize:=Ablocksize ;
num:=0 ;
end;
destructor TGemCollector.Destroy;
var s:string ;
begin
for s in list do
TPony.SendGem('rarity'+IntToStr(num),s) ;
list.Free ;
inherited Destroy;
end;
end.
Заказчик оплатил, код отправлен в репозиторий. Всё хорошо?
Нет.
Почему так получилось?
1) Код сильно усложнился, а проверить его невозможно — у нас нет доступа к исходникам с классом TPony, соответственно, отправить класс в тестовую среду мы не можем.
2) Формирование имени пони 'rarity'+IntToStr(num) дублируется в двух местах.
3) Цикл отправки for s in list do дублируется в двух местах
4) Мы заменили публичный конструктор, и теперь везде, где был задействован старый класс, его не смогут скомпилировать в проекте.
5) Мы молча убрали старое поведение класса, а ведь никто не обещал, что рано или поздно его не попросят вернуть обратно или сделать опцией.
Иными словами, мы создали плохую архитектуру.
И что с этим делать? Очевидно, что у нас есть два вида поведения класса — полная отправка единым пакетом и отправка по мере накопления заданного количества, причем первая не имеет параметров, а вторая — имеет. Плюс нужно вынести эту логику за пределы юнита, чтобы была возможность её протестировать без привязки к чужому классу TPony.
Итак, это паттерн «Стратегия» — мы выделяем отдельный класс, отвечающий за логику накопления и отправки данных, и реализуем для него два наследника, один из которых работает по старой схеме, а другой создается с заданным размером пакета и отправляет по мере накопления. В коде основного класса будем создавать конкретную стратегию и передавать ей параметры, а также обратную функцию для отправки накопленных данных.
Класс абстрактной стратегии
TGemSenderProc = procedure(const ponyname:string; const gem:string) of object ;
TCollectorStrategy = class
protected
list:TStringList ;
senderproc:TGemSenderProc ;
ponyname:string ;
procedure SendAllGems() ;
function GetPonyName():string ; virtual ; abstract ;
public
constructor Create(Asenderproc:TGemSenderProc; const Aponyname:string) ;
destructor Destroy ; override ;
procedure AddGem(const gem:string) ; virtual ; abstract ;
end;
constructor TCollectorStrategy.Create(Asenderproc: TGemSenderProc; const Aponyname:string);
begin
senderproc:=Asenderproc ;
ponyname:=Aponyname ;
list:=TStringList.Create ;
end;
destructor TCollectorStrategy.Destroy;
begin
SendAllGems() ;
list.Free ;
inherited Destroy ;
end;
procedure TCollectorStrategy.SendAllGems;
var s:string ;
begin
for s in list do
senderproc(getPonyName(),s) ;
end;
Сам по себе класс умеет отправлять в переданный ему метод накопленные строки и делает это в деструкторе.
Чтобы класс заработал, нужно в потомках реализовать методы AddGem и GetPonyName, которые отвечают за формирование имени пони и добавления строк.
Сделаем это:
Реализация старой стратегии
TCollectorStrategySolid = class(TCollectorStrategy)
protected
function GetPonyName():string ; override ;
public
procedure AddGem(const gem:string) ; override ;
end;
procedure TCollectorStrategySolid.AddGem(const gem: string);
begin
list.Add(gem) ;
end;
function TCollectorStrategySolid.GetPonyName: string;
begin
Result:=ponyname ;
end;В ней нет ничего нового — просто добавляем строки в список и в качестве имени пони, используем предопределенную строку.
Реализация стратегии с размером блока
TCollectorStrategyBlocks = class(TCollectorStrategy)
private
num:Integer ;
blocksize:Integer ;
protected
function GetPonyName():string ; override ;
public
constructor Create(Asenderproc:TGemSenderProc; const Aponyname:string; Ablocksize:Integer) ;
procedure AddGem(const gem:string) ; override ;
end;
procedure TCollectorStrategyBlocks.AddGem(const gem: string);
begin
list.Add(gem) ;
if list.Count>=blocksize then begin
SendAllGems() ;
list.Clear() ;
Inc(num) ;
end;
end;
constructor TCollectorStrategyBlocks.Create(Asenderproc: TGemSenderProc;
const Aponyname: string; Ablocksize: Integer);
begin
inherited Create(Asenderproc,Aponyname) ;
blocksize:=Ablocksize ;
num:=0 ;
end;
function TCollectorStrategyBlocks.GetPonyName: string;
begin
Result:=ponyname+IntToStr(num) ;
end;
А вот тут уже новая механика — добавляет размер блока, счетчик и в методе добавления, мы контролируем размер и производим отправку/очистку по достижению количества, плюс в коде метода имени, добавляется число к строке имени. Обращаем внимание, что всё это вынесено в отдельный класс без зависимостей — теперь мы сможем проверить эту механику в тестовой среде.
Теперь переделаем наш заказанный класс на использование стратегий:
Класс с использованием стратегий
unit GemCollector;
interface
uses Classes, CollectorStrategy ;
type
TGemCollector = class
private
cs:TCollectorStrategy ;
procedure SendGem(const ponyname:string; const gem:string) ;
public
constructor Create() ;
constructor CreateWithBlockSize(Ablocksize:Integer) ;
destructor Destroy ; override ;
procedure AddGem(const gem:string) ;
end;
implementation
uses Pony, SysUtils ;
{ TGemCollector }
procedure TGemCollector.AddGem(const gem: string);
begin
cs.AddGem(gem) ;
end;
constructor TGemCollector.Create();
begin
cs:=TCollectorStrategySolid.Create(SendGem,'rarity') ;
end;
constructor TGemCollector.CreateWithBlockSize(Ablocksize:Integer);
begin
cs:=TCollectorStrategyBlocks.Create(SendGem,'rarity',Ablocksize) ;
end;
destructor TGemCollector.Destroy;
begin
cs.Free ;
inherited Destroy;
end;
procedure TGemCollector.SendGem(const ponyname:string; const gem: string);
begin
TPony.SendGem(ponyname,gem) ;
end;
end.Во-первых, мы оставили старый и добавили новый конструктор. В старом создается старая стратегия, а в новом — новая. Далее строки передаются в объект стратегии, которому при создании был указан метод обратного вызова, внутри которого и идет отправка в целевой класс TPony. Всё это не засоряет основной код и открыто к добавлению новых вариантов стратегий, например, если заказчик пожелает отдельно отправлять строки с четными и нечетными номерами.
Вроде всё хорошо, но давайте еще реализуем тесты. У нас есть такая возможность, поскольку классы стратегий отвязаны от основного модуля.
Тесты для старой и новой стратегий
unit TestCollectorStrategy;
interface
uses
DUnitX.TestFramework,
Classes;
type
[TestFixture]
TTestCollectorStrategy = class
private
list:TStringList ;
procedure SenderProc(const ponyname:string; const gem:string) ;
public
[SetUp]
procedure SetUp() ;
[TearDown]
procedure TearDown() ;
[Test]
procedure TestSolid();
[Test]
procedure TestBlocks();
end;
implementation
uses CollectorStrategy ;
procedure TTestCollectorStrategy.SenderProc(const ponyname, gem: string);
begin
list.Add(ponyname+'_'+gem) ;
end;
procedure TTestCollectorStrategy.SetUp;
begin
list:=TStringList.Create() ;
end;
procedure TTestCollectorStrategy.TearDown;
begin
list.Free ;
end;
procedure TTestCollectorStrategy.TestSolid;
var cs:TCollectorStrategy ;
begin
cs:=TCollectorStrategySolid.Create(SenderProc,'rarity') ;
cs.AddGem('gem1') ;
cs.AddGem('gem2') ;
cs.AddGem('gem3') ;
Assert.AreEqual(0,list.Count) ;
cs.Free ;
Assert.AreEqual(3,list.Count) ;
Assert.AreEqual('rarity_gem1',list[0]) ;
Assert.AreEqual('rarity_gem2',list[1]) ;
Assert.AreEqual('rarity_gem3',list[2]) ;
end;
procedure TTestCollectorStrategy.TestBlocks;
var cs:TCollectorStrategy ;
begin
cs:=TCollectorStrategyBlocks.Create(SenderProc,'rarity',2) ;
cs.AddGem('gem1') ;
Assert.AreEqual(0,list.Count) ;
cs.AddGem('gem2') ;
Assert.AreEqual(2,list.Count) ;
Assert.AreEqual('rarity0_gem1',list[0]) ;
Assert.AreEqual('rarity0_gem2',list[1]) ;
cs.AddGem('gem3') ;
Assert.AreEqual(2,list.Count) ;
cs.AddGem('gem4') ;
Assert.AreEqual(4,list.Count) ;
Assert.AreEqual('rarity1_gem3',list[2]) ;
Assert.AreEqual('rarity1_gem4',list[3]) ;
cs.AddGem('gem5') ;
Assert.AreEqual(4,list.Count) ;
cs.Free ;
Assert.AreEqual(5,list.Count) ;
Assert.AreEqual('rarity2_gem5',list[4]) ;
end;
initialization
TDUnitX.RegisterTestFixture(TTestCollectorStrategy);
end.Как и ожидалось, тесты успешно выполняются и мы теперь спокойны за работу класса. Архитектура кода стала намного лучше.
Что почитать, дабы больше узнать о том, как организовывать свои классы:
1) Конкретно по данному паттерну — https://ru.wikipedia.org/wiki/Стратегия_(шаблон проектирования)
2) В целом по проектированию — если начать с классики, то проверенная временем «Банда четырех» с паттернами проектирования: ru.wikipedia.org/wiki/Design_Patterns
3) Если же хочется именно практических советов, то Мартин Фаулер «Рефакторинг: улучшение проекта существующего кода». Там затронуты и другие проблемы, вроде дублирования кода и избавления от зависимостей.
Спасибо, всем хорошего кода.
Все, кто дочитал до сюда — есть еще вторая статья, но она более сложная и к ней еще не готовы примеры кода. Возможно, попозже, как будет время.
16 комментариев
Как мне пропатчить KDE2 под FreeBSD?
За статью спасибо, полезно!
— если проект весьма большой, а разработчик не лазил в эту часть кода — порой он рискует потратить кратно больше времени для осознания всего происходящего. Линейный код одинаковой сложности всегда почти читать проще (не поддерживать);
— если стратегии по какой-то причине получаются несимметричные (в одном случае фича нужна, а в другом нет) получаются либо очень странные хвосты, либо приходится всё-таки разносить логику одной сущности по разным местам, что ещё хуже.
Местами применимо (когда стратегий накапливается очень много, и они очень похожи — но при этом не настолько элементарны, чтобы просто гигантский switch/case в одном/двух местах решал задачу), но и про обыкновенные абстрактные классы и наследования не надо забывать (да и дублирование кода всё-таки проще лечить приватными/протектед методами). Статье не хватает аргументации, когда именно паттерн полезен.
Эту задачу в общем виде даже умные авторы не решают, увы. Любой паттерн — это рецепт с рекомендациями, а не жесткими правилами, почти как пиратский кодекс :-)
У такого подхода один минус, но перевешивает всё. Если в стратегии забудешь реализовать абстрактный метод, то об это скажет либо компилятор, либо рантайм. Если в цепочке switch не дописать новую реализацию — неработающая функция будет просто не работать, без предупреждений и ошибок.
На практике, уже при связанном ветвлении в двух местах — рекомендуют выносить подкласс.
Не ручаюсь за остальные языки, но в typescript есть конструкция, которая это может ловить на этапе «компиляции» (в полностью «разобранном» случае переменная в последнем default имеет тип never, и можно сделать присвоение. Если там окажется какое-то возможное значение, оно заорёт)
Да и аргумент
относится скорее к контролю версий, а не архитектуре.
Вот поэтому, я думаю, и Паскаль. Его до сих пор используют в школах для обучения программированию, насколько я знаю.