понедельник, 11 ноября 2013 г.

Шаблоны в Delphi (но не дженерики)

Об этом уже писали: http://edn.embarcadero.com/article/27603 или вот http://18delphi.blogspot.ru/2013/09/templates-in-object-pascal.html.

Напишу своими словами, надеюсь в доступной для массового читателя форме.

Итак, допустим у нас есть пара классов: TSomeType1 и TSomeType2, которые объявлены в SomeUnit.pas. И допустим, что эти классы имеют одинаковый набор некоторых свойств и методов, но при этом эти свойства и методы не объявлены у их общего предка. К примеру, пусть будет так:

unit SomeUnit;

interface 

type
  TSomeType1 = class(TObject)
  // ..
  public
    procedure DoSomething;
  end;

  TSomeType2 = class(TObject)
  // ..
  public
    procedure DoSomething;
  end;

  ..
end.

Здесь видим метод DoSomething, который есть у обоих классов. Но это два разных метода.

HINT: И допустим, что у нас нет возможности вносить изменения в SomeUnit.pas. Это важно для понимания, почему именно такой подход я описываю.

А теперь допустим, что у нас есть задача: написать класс, который будет работать с экземплярами наших классов, вызывая у них DoSomething (по некоторым событиям). А т.к. TSomeType1 и TSomeType2 – это разные классы, то нам придётся писать два новых класса. Назовём их T1 и T2 соответственно. Как это можно сделать? Ну, например, в лоб (файл MyUnit.pas):

unit MyUnit;

uses SomeUnit;

interface

type
  T1 = class(TObject)
  private
    FObject: TSomeType1;
  public
    constructor Create(AObject: TSomeType1);
  end;
  
  T2 = class(TObject)
  private
    FObject: TSomeType2;
  public
    constructor Create(AObject: TSomeType2);
  end;

implementation

constructor T1.Create(AObject: TSomeType1);
begin
  FObject := AObject;
  FObject.DoSomething;
end;

constructor T2.Create(AObject: TSomeType2);
begin
  FObject := AObject;
  FObject.DoSomething;
end;

end.

HINT: Здесь вызов DoSomething происходит всего один раз в конструкторе классов T1 и T2. Это совсем не практично, но это всего лишь пример, на котором я хочу продемонстрировать описываемую технику.

И использовать это где-то в коде так:

uses SomeUnit, MyUnit;
..
var
  Obj1: TSomeType1;
  Obj2: TSomeType2;
  ObjA: T1;
  ObjB: T2;
..
  ObjA := T1.Create(Obj1);
  ObjB := T2.Create(Obj2);
..

А теперь представьте, что наши классы T1 и T2 делают гораздо больше работы. Т.е. кода будет больше. Но при этом, код будет совпадать. Налицо – дублирование кода. Как же его избежать?

Мысль первая – обобщения

Попробуем обобщения, они же – дженерики. Наш MyUnit.pas будет выглядеть так:

unit MyUnit;

uses SomeUnit;

interface

type
  TX<T> = class(TObject)
  private
    FObject: T;
  public
    constructor Create(AObject: T);
  end;
  T1 = TX<TSomeType1>;
  T2 = TX<TSomeType2>;
  
implementation

constructor TX.Create(AObject: T);
begin
  FObject := AObject;
  FObject.DoSomething;
end;

end.

Но, к сожалению, такой код не скомпилируется. Потому что для компиляции строки FObject.DoSomething нужно знать, что метод DoSomething есть у типа T, а этого компилятор знать просто не может. И я не нашел способов это каким-либо образом определить. Можно явно указать, от какого класса должен быть унаследован тип T, или какой интерфейс тип T должен поддерживать.  Но в нашем примере это не подходит (мы же не можем вносить правки в SomeUnit.pas, помните?).

UPD: В комментариях мне напомнили ещё и про RTTI. Да, начиная с Delphi 2010, с использованием RTTI можно достучаться даже до приватных полей объекта. Но мне это не нравится в плане читаемости кода и скорости исполнения.

Мысль вторая – шаблоны кода

Попробуем include-файлы (обычные inc-файлы, которые существуют в Delphi в наследство от Pascal’я). Идея простая: дублирующийся код выносим в inc-файл, который подставляем в нужных нам местах директивой {$I filename}. Смотрите, создадим файл X.inc с таким содержанием:

  type
    TX = class(TObject)
    private
      FObject: TReplacement;
    public
      constructor Create(AObject: TReplacement);
    end;

implementation

constructor TX.Create(AObject: TReplacement);
begin
  FObject := AObject;
  FObject.DoSomething;
end;

end.

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

Файл "A.pas":

unit A;

interface

uses SomeUnit;

type
  TX = class; // forward declaration
  T1 = TX;
  TReplacement = TSomeType1;
  {$i X.inc}

Файл "B.pas":

unit B;

interface

uses SomeUnit;

type
  TX = class; // forward declaration
  T2 = TX;
  TReplacement = TSomeType2;
  {$i X.inc}

HINT: Тут я немного схитрил. Чтобы не делать два inc-файла (один для интерфейсной части, а второй для части реализации – как это предлагают авторы по ссылкам выше), в модулях A и B, перед включением inc-файла, добавил forward declaration (строка номер 8) для класса TX (который и описывается в inc-файле). После чего этого я могу напрямую сослаться на этот класс (ещё до его интерфейсной части – строка 9). В строке 10 указывается тот тип данных, который будет использоваться inc-файлом вместо TReplacement.

HINT: Если Вы не совсем понимаете, что тут происходит, то просто подставьте содержимое файла X.inc в файлы A.pas и B.pas вместо директивы {$i X.inc}. И Вы увидите, что получилось два полноценных модуля.

HINT: Строго говоря, дублирования исполняемого кода (как и с дженериками) мы не избежали. При компиляции нашего примера inc-файл будет подставлен два раза. Но нам удалось избежать дублирования на уровне исходного кода – а это, порой, многого стоит.

После этого, наш модуль MyUnit.pas надо переписать, он станет таким:

unit MyUnit;

uses A, B;

interface

type
  T1 = A.T1;
  T2 = B.T2;

implementation

end.

И мы можем его использовать точно так же, как это было показано выше.

Зачем это нужно

В случае, когда нет возможности внести изменения в исходный модуль типа SomeUnit.pas, такая техника может оказаться единственной, позволяющей решить подобные задачи.

Например, в VCL есть компонент TCombobox. В режиме Style = csSimple или Style = csDropdown он работает как обычный TEdit. И у TCombobox и у TEdit есть набор схожих свойств: MaxLength, SelStart, SelLength, SelText и т.п. Но эти свойства объявлены не в их общем предке (TWinControl), а в классах TCustomComboBox и TCustomEdit соответственно.

В предыдущей заметке я описывал, как можно немного изменить стандартное поведение свойства MaxLength для наследников от TCustomEdit (т.е. TEdit, TMemo и т.п.). А для того, чтобы его можно было применить и к TCustomComboBox – воспользуемся описанной выше техникой.

Тестовое приложение:

image

Скачать: исходник или тестовое приложение.

Плюсы и минусы данной техники

Плюсы:

  • Мы решили частную задачу, когда нет возможности (или нет желания) внести изменения в исходный код сторонней библиотеки.
  • Мы избежали дублирования кода.

Минусы:

  • Когда мы выносим код в inc-файл, надо чуть больше воображения, чем обычно: придумать имена для файлов и замещаемых типов, а также представлять, как это будет выглядеть в итоге.
  • IDE, работая с inc-файлами, заранее не знает, в какие места этот inc-файл будет подставляться. Как следствие – тут не работает CodeInsight, могут возникнуть проблемы с рефакторингом и автоматическим форматированием кода.

И такой нюанс. Отладчик с inc-файлами работает так же, как и с обычными pas-файлами. Но (как и с дженериками) ставя точку останова в inc-файле, это точка включается и для T1 и для T2. Решается указанием условия для точки останова, например: Self is T1.

14 коммент.:

Александр Люлин комментирует...

bq. Тут я немного схитрил. Чтобы не делать два inc-файла (один для интерфейсной части, а второй для части реализации)

А я писал же - как не делать. Через IfDef.

Александр Люлин комментирует...

Немного позанудничаю. Create конкретно здесь - лишний :-)
Если ты пишешь вспомогательный класс, без собственного состояния - достаточно class function. Для дёрганья методов - TReplacement.

Александр Люлин комментирует...

"Строго говоря, дублирования исполняемого кода (как и с дженериками) мы не избежали."

И НИ В ОДНОМ другом языке со статической типизацией - не избежим.

Ну а динамическая - вносит дополнительные накладные расходы.

deksden комментирует...

Это такой duck typing? А-ля оксигеновский http://wiki.oxygenelanguage.com/en/Duck_Typing ? Жаль что в дельфях подобного нету "из коробки"!

Анонимный комментирует...

Inc-файлы - зло. Как правило отваливается рефакторинг и нормальная навигация по коду. В подобных задачах проще и правильнее использовать интерфейсы

Николай Зверев комментирует...

Спасибо за комментарии.

Александр Люлин
> А я писал же - как не делать. Через IfDef.
Это мы обсуждали немного другой вопрос. "{$IFDEF control_is_combo}" vs "if Control is TCombobox then". Скачайте исходник по ссылке из заметки - там как раз дефайны и используются.

> Немного позанудничаю
Да пожалуйста :) Только конкретный пример (не исходник по ссылке) - это всего лишь пример.
В исходнике - как раз с собственным состоянием. Одними класс-методами там не обойтись.


deksden
Да, в делфи порой не хватает всяких таких вкусностей...


Анонимный
> Inc-файлы - зло.
Да, у inc-файлов есть недостатки. IDE с ними плохо работает. Но отладчик вполне себе дружит.

> В подобных задачах проще и правильнее использовать интерфейсы
Согласен. Но иногда по другому никак. Как в примере с VCL, или другими сторонними библиотеками - не вносить же изменения в "чужой" и обновляемый код?

Ярослав комментирует...

Добрый день.

Если я правильно понял и мы можем дополнять реализацию FObject: TSomeType1, то есть еще вариант через посылку сообщения TObject.Dispatch. Тогда можно будет оформить шаблонный класс TX и слать сообщение с заранее известным кодом. А в объектах TSomeType1 принимать его и выполнять нужные действия.

Плюсы:
1. Слабая зависимости от TSomeType без интерфейсов и строгих знаний о всех доступных методах класса.
2. Можно использовать шаблоны.
Минус:
1. Требуется создать отдельный метод для приема сообщения и делать вызов DoSomeMethod.
2. При росте количества методов требуется или выделять отдельные типы сообщений или использовать шаблоны проектирования типа команд, стратегий и прочих...

Анонимный комментирует...

В данной постановке задачи возможно имеет смысл использовать директиву компилятора
$typeinfo ($M) и реализовать вызов требуемого метода средствами rtti

Николай Зверев комментирует...

Ярослав
Простите, но либо я Вас не так понял, либо Ваше замечание не по теме. Обратите внимание, что в данном примере создаётся два объекта: один Obj1: TSomeType1, а другой ObjA: T1; второй объект управляет первым.
И, наверное, тут RTTI будет более уместным, чем Dispatch, который предназначен для обработки сообщений.

Анонимный
Спасибо, про RTTI я не стал писать сознательно, но раз Вы вспомнили - добавил.
Ещё добавил в заметку заметку пару хинтов, написал про плюсы/минусы.

Всем
Хочу акцентировать внимание: надо понимать, что экземляры T1 могут создаваться не для всех экземпляров TSomeType1. Т.е. "наращивать" функционал можно для "избранных" объектов (а не для всех).

Лично мне такая техника не нравится. Практика показывает, что если есть возможность, то лучше вводить промежуточный абстрактный класс, либо интерфейсы.
Но в заметке приведён реальный пример, где это работает и это используется в наших проектах. (И это совместимо со старыми версиями Delphi.)

А ещё отдельное спасибо Александру Люлину. За то, что постами в своём блоге напомнил про эту технику.

Александр Краснояров комментирует...

//насчет более медленного решения с rtti замечено верно, однако его использование imho в таких задачах является предпочтительней.
program RTTICallMethodByName;

{$APPTYPE CONSOLE}

{$R *.res}

uses
System.SysUtils,
System.Rtti;

type
{$M+}
TSomeType1 = class(TObject) public procedure DoSomething; end;
TSomeType2 = class(TObject) public procedure DoSomething; end;
{$M-}
procedure TSomeType2.DoSomething; begin Writeln('TSomeType2'); end;
procedure TSomeType1.DoSomething; begin Writeln('TSomeType1'); end;

//вызов метода по имени
procedure _RTTICallMethodByName(aObj: TObject; aMethodName: string);
var
_RttiCtx: TRttiContext;
_riType: TRttiType;
_riMethod: TRttiMethod;
begin
_RttiCtx:= TRttiContext.Create;
_riType:= _RttiCtx.GetType(aObj.ClassType);
_riMethod:= _riType.GetMethod(aMethodName);
if not assigned(_riMethod) then raise Exception.Create(format('not faund method "%s"',[aMethodName]));
_riMethod.Invoke(aObj,[]);
end;

var
_Obj1: TSomeType1; _Obj2: TSomeType2;
begin
try
try
_Obj1:= TSomeType1.Create;
_Obj2:= TSomeType2.Create;
_RTTICallMethodByName(_obj1,'DoSomething');
_RTTICallMethodByName(_obj2,'DoSomething');
_RTTICallMethodByName(_obj2,'DoSomething_test');
finally
_Obj1.Free;
_Obj2.Free;
end;
Readln;
except
on E: Exception do begin Writeln(E.ClassName, ': ', E.Message); readln; end;
end;
end.

Анонимный комментирует...

type
TSomeType1 = class(TComponent)
public
procedure DoSomething;
end;

TSomeType2 = class(TComponent)
public
procedure DoSomething;
end;

IDoSomething = interface
procedure DoSomething;
end;

TISomeType2 = class(TSomeType2, IDoSomething);
TISomeType1 = class(TSomeType1, IDoSomething);

Александр Люлин комментирует...

Одного только вы не поняли... Это НЕ Duck-Typing... Это ПРИМЕШИВАНИЕ функциональности...

samsim комментирует...

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

Анонимный комментирует...

RTTI нельзя использовать в дженериках т.к. Method.Invoke(T,[]) - скажет Вам о недопустимости приведения типов. А использовать один класс который будет дёргать методы других классов не всегда вписывается в архитектуру

Отправить комментарий

.

.