О хорошей и плохой архитектуре или как расширять функции кода

?
NTFSв блоге IT Pony!4 февраля 2026, 13:27
Всем добрый день.

Был у меня заказ на несколько статей по 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 комментариев

В программистскую ленту
NTFS
+4
А где домики?
AlisarDoUrden
+3
Познавательно.
dsmith
+1
Здравствуйте, это сайт по поням?
Как мне пропатчить KDE2 под FreeBSD?
TheOceanPony
+3
Да, лютый оффтопик, но жалко, если уже готовую статью выкидывать в корзину. А так здесь немало разработчиков пасется.
NTFS
+3
Просто напомнило)
За статью спасибо, полезно!
TheOceanPony
0
как выйти из vim
zpn_1999
0
Я так и не смог, потому везде перенастраиваю на mcedit
NTFS
+1
Из практики у стратегий есть два относительно жирных минуса:
— если проект весьма большой, а разработчик не лазил в эту часть кода — порой он рискует потратить кратно больше времени для осознания всего происходящего. Линейный код одинаковой сложности всегда почти читать проще (не поддерживать);
— если стратегии по какой-то причине получаются несимметричные (в одном случае фича нужна, а в другом нет) получаются либо очень странные хвосты, либо приходится всё-таки разносить логику одной сущности по разным местам, что ещё хуже.

Местами применимо (когда стратегий накапливается очень много, и они очень похожи — но при этом не настолько элементарны, чтобы просто гигантский switch/case в одном/двух местах решал задачу), но и про обыкновенные абстрактные классы и наследования не надо забывать (да и дублирование кода всё-таки проще лечить приватными/протектед методами). Статье не хватает аргументации, когда именно паттерн полезен.
StaSyaN (ред.)
+3
Статье не хватает аргументации, когда именно паттерн полезен.

Эту задачу в общем виде даже умные авторы не решают, увы. Любой паттерн — это рецепт с рекомендациями, а не жесткими правилами, почти как пиратский кодекс :-)

гигантский switch/case в одном/двух местах

У такого подхода один минус, но перевешивает всё. Если в стратегии забудешь реализовать абстрактный метод, то об это скажет либо компилятор, либо рантайм. Если в цепочке switch не дописать новую реализацию — неработающая функция будет просто не работать, без предупреждений и ошибок.
На практике, уже при связанном ветвлении в двух местах — рекомендуют выносить подкласс.
NTFS (ред.)
+3
Если в цепочке switch не дописать новую реализацию — неработающая функция будет просто не работать, без предупреждений и ошибок.

Не ручаюсь за остальные языки, но в typescript есть конструкция, которая это может ловить на этапе «компиляции» (в полностью «разобранном» случае переменная в последнем default имеет тип never, и можно сделать присвоение. Если там окажется какое-то возможное значение, оно заорёт)
StaSyaN (ред.)
0
О, привет, Хабр! Ой, подождите-ка… :)
Mainframe
+2
Для хабра статья слишком примитивная. НЯМС, её заказывали для школьного журнала. Отсюда и язык, и упрощенка (внимательный кодер заметит, что я использовал функцию как объект, хотя по уму, там вместо функции обратного вызова должен быть объект с интерфейсом, реализующим GemSenderProc)
Да и аргумент
Мы молча убрали старое поведение класса, а ведь никто не обещал, что рано или поздно его не попросят вернуть обратно или сделать опцией.

относится скорее к контролю версий, а не архитектуре.
NTFS (ред.)
+2
В Паскале я не очень продвинулся, для меня оказалось, что существуют языки поинтереснее. C, D, Python там.
Mainframe
0
её заказывали для школьного журнала


Вот поэтому, я думаю, и Паскаль. Его до сих пор используют в школах для обучения программированию, насколько я знаю.
Randy1974
+1
Я не задаю вопросов, пока мне платят деньги, но думаю да, либо для школьного журнала, либо для какой статьи младших курсов. Впрочем, материал сам по себе полезен, принцип тот же, что и для C#/C++/Java, на таком уровне, организация кода схожая. Отличия в некоторых деталях вроде реализации интерфейсов или областей видимости внутри юнита (в последних версиях ObjectPascal это исправили).
NTFS
+2
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.