Записи в категории ·

Без рубрики

· Category...

Анонс DelphiSpec

В блоге я уже несколько раз писал о предметно-ориентированных языках (DSL). Эта моя заметка будет куда более приближена к реальной жизни, так что не закрывайте пока страницу.

Не буду повторять сам себя, процитирую Фаулера:

That said, I do think that the greatest potential benefit of DSLs comes when business people participate directly in the writing of the DSL code. The sweet spot, however is in making DSLs business-readable rather than business-writeable. If business people are able to look at the DSL code and understand it, then we can build a deep and rich communication channel between software development and the underlying domain.

Наибольший потенциал DSL он видит в том, что текст на предметно-ориентированном языке могут писать и — главное — читать эксперты предметной области, а вовсе не программисты. Благодаря этому можно обеспечить совершенно другой уровень коммуникации между разработкой ПО и предметной областью.

В этом посте я хотел бы анонсировать библиотеку DelphiSpec. Не буду скрывать, что вдохновил меня проект Cucumber. То есть фактически библиотека является Delphi-реализацией языка Gherkin (переводится как «корнишон»). Реализация не полная (считайте альфа-версией). Некоторых возможностей пока нет, но проект будет развиваться. Мне он интересен.

Если в двух словах техническим языком выразить суть проекта, то DelphiSpec позволяет автоматически превратить подобный сценарий в тест для старого доброго DUnit:

Feature: Calculator

Scenario: Add two numbers
  Given I have entered 50 in calculator
    And I have entered 70 in calculator
  When I press Add
  Then the result should be 120 on the screen

Scenario: Add two numbers (fails)
  Given I have entered 50 in calculator
    And I have entered 50 in calculator
  When I press Add
  Then the result should be 120 on the screen

Scenario: Multiply three numbers
  Given I have entered 5 in calculator
    And I have entered 5 in calculator
    And I have entered 4 in calculator
  WHEN I press mul
  Then the result should be 100 on the screen

Подчеркну, это не тесты, это сценарии. То есть настоящий Behavior-Driven Development (BDD) в дополнение к известному Test-Driven Development (TDD). Такие сценарии могут использоваться как часть постановки задачи, как часть документации к проекту и конечно как тесты. С помощью сценариев не принято описывать внутренние особенности системы, сценарии описывают как происходящее выглядит для пользователя.

Выбор языка Gherkin в качестве базового для библиотеки DelphiSpec хорош тем, что в определенной нише это вполне себе установившийся стандарт и, заинтересовавшись, вы даже сможете купить книгу с лучшими практиками, советами по написанию сценариев и примерами на этом языке. В интернете информации так же достаточно.

Вернемся к примеру с калькулятором. Результат выполнения сценариев в DUnit будет выглядеть так:
dunit-delphispec

Как это работает? Сам язык Gherkin очень мал. По сути единственная его функция — это описание связок Given-When-Then (по-русски: дано-если-то). Есть несколько ключевых слов, все остальное кастомизируется.

В примере мы тестируем очень простой класс-калькулятор. Здесь я его код выкладывать не буду, смотрите его по ссылке. Интересно другое: как программе понять и выполнить команды вроде «Given I have entered 50 in calculator»? Вот тут в дело вступает вспомогательный класс описывающий эти шаги (так называемые «step definitions»). Выглядит класс так:

unit SampleCalculatorStepDefs;

interface

uses
  SampleCalculator, DelphiSpec.Attributes, DelphiSpec.StepDefinitions;

type
  [_Feature('calculator')]
  TSampleCalculatorSteps = class(TStepDefinitions)
  private
    FCalc: TCalculator;
  public
    procedure SetUp; override;
    procedure TearDown; override;

    [_Given('I have entered (.*) in calculator')]
    procedure EnterInt(Value: string);

    [_When('I press Add')]
    procedure AddInt;

    [_When('I press Mul')]
    procedure MulInt;

    [_Then('the result should be (.*) on the screen')]
    procedure TestResult(Value: string);
  end;

implementation

uses
  System.SysUtils, TestFramework, DelphiSpec.Core;

{ TSampleCalculatorSteps }

procedure TSampleCalculatorSteps.AddInt;
begin
  FCalc.Add;
end;

procedure TSampleCalculatorSteps.EnterInt(Value: string);
begin
  FCalc.Push(Value.ToInteger);
end;

procedure TSampleCalculatorSteps.MulInt;
begin
  FCalc.Mul;
end;

procedure TSampleCalculatorSteps.SetUp;
begin
  FCalc := TCalculator.Create;
end;

procedure TSampleCalculatorSteps.TearDown;
begin
  FCalc.Free;
end;

procedure TSampleCalculatorSteps.TestResult(Value: string);
begin
  if FCalc.Value <> Value.ToInteger then
    raise ETestFailure.Create('Incorrect result on calculator screen');
end;

initialization
  RegisterStepDefinitionsClass(TSampleCalculatorSteps);

end.

То есть по сути у каждого шага есть тип (Given/When/Then) и регулярное выражение, описывающее остальную часть шага. Регулярные выражения помогают выделить из шагов параметры, которые будут переданы в методы класса для выполнения соответствующей части сценария.

Чтобы вся эта система заработала, остается только добавить в dpr-файл вызов процедуры, получающей на вход путь к файлам с описанием сценариев и превращающей эти сценарии в настоящие тесты для DUnit.

begin
  PrepareDelphiSpecs(TPath.Combine(ExtractFilePath(Application.ExeName), 'features'), True, 'EN');
  DUnitTestRunner.RunRegisteredTests;
end.

Последний параметр («EN») позволяет указать буквенный код языка, на котором написаны сценарии. Это еще одна интересная возможность Gherkin. Так как в общем-то нет разницы текст на каком языке обрабатывать с помощью регулярных выражений, то грех было не перевести на разные языки и несколько ключевых слов, для чего служит соответствующий XML-файл.

То есть ничто не мешает записывать сценарии даже вот так:

Функционал: Калькулятор

Сценарий: Сложить два числа
  Допустим я ввожу 50 на калькуляторе
         И я ввожу 70 на калькуляторе
  Когда я нажимаю СЛОЖИТЬ
  Тогда результат на экране должен быть 120

Это пока первые шаги, но постепенно я планирую реализовать все возможности Gherkin.

Delphi XE5: modal forms are not modal?

A couple of months ago Marco Cantu told us that modal forms are not directly supported on Android and we could use a new overloaded version of the same method:

    function ShowModal: TModalResult; overload;
    procedure ShowModal(const ResultProc: TProc<TModalResult>); overload;

First method does raise an exception on Android and the second can be used as follows.
This classic code:

var
  dlg: TForm1;
begin
  dlg := TForm1.Create(nil);
  try
    // select current value, if avaialble in the list
    dlg.ListBox1.ItemIndex := dlg.ListBox1.Items.IndexOf(edit1.Text);
    if dlg.ShowModal = mrOK then
      // if OK was pressed and an item is selected, pick it
      if dlg.ListBox1.ItemIndex >= 0 then
        edit1.Text := dlg.ListBox1.Items [dlg.ListBox1.ItemIndex];
  finally
    dlg.Free;
  end;
end;

should be rewritten using the new approach:

var
  dlg: TForm1;
begin
  dlg := TForm1.Create(nil);
  // select current value, if avaialble in the list
  dlg.ListBox1.ItemIndex := dlg.ListBox1.Items.IndexOf(Edit1.Text);
  dlg.ShowModal(
    procedure(ModalResult: TModalResult)
    begin
      if ModalResult = mrOK then
      // if OK was pressed and an item is selected, pick it
        if dlg.ListBox1.ItemIndex >= 0 then
          edit1.Text := dlg.ListBox1.Items [dlg.ListBox1.ItemIndex];
      dlg.DisposeOf;
    end);
end;

The idea is clear and the motivation is clear. It uses an anonymous method with the code to be executed when the «supposedly modal» form is closed. This code works on all platforms. Thanks, Marco!

But let’s take a deeper look inside this new ShowModal:

procedure TCommonCustomForm.ShowModal(const ResultProc: TProc<TModalResult>);
begin
  FResultProc := ResultProc;
  Show;
end;

The form will be not modal on any platform! Thus, if we are going to write cross-platform code, we are suggested either to use conditional compilation or to stop using modal forms at all. Seriously?

Here is the pattern that I often use in my code:

with TForm1.Create(nil) do
try
  ShowModal;
finally
  Free;
end;

Taking into account new ShowModal approach, this code can be rewritten as follows:

var
  Frm: TForm1;
begin
  Frm := TForm1.Create(nil);
  ShowModal(procedure (Res: TModalResult) begin Frm.DisposeOf; end);
end;

It works fine on all platforms, but the form is not modal anymore! So the best option for me would be:

var
  Frm: TForm1;
begin
  Frm := TForm1.Create(nil);
  {$IFDEF ANDROID}
    Frm.ShowModal(procedure (Res: TModalResult) begin end);
  {$ELSE}
    try
      Frm.ShowModal;
    finally
      Frm.Free;
    end;
  {$ENDIF}
end;

The form will be «pseudomodal» on Android and truly modal on other platforms. It’s better than nothing, but I hesitate to use IFDEFs too often — usually I have more than a couple of forms.

Finally, I have two rhetorical questions.

Why was new ShowModal approach not implemented like this:

procedure TCommonCustomForm.ShowModal(const ResultProc: TProc<TModalResult>);
begin
  {$IFDEF ANDROID}
    FResultProc := ResultProc;
    Show;
  {$ELSE}
    ResultProc(ShowModal);
  {$ENDIF}
end;

?

If this is not suitable for some reason, why did you call this method ShowModal, it has nothing to do with modal forms though?

Кроссплатформенность и модальные формы

Пару месяцев назад Марко Канту порадовал нас информацией о том, что Android не поддерживает модальные формы и поэтому теперь у нас в Delphi XE5 будет два разных метода ShowModal:

    function ShowModal: TModalResult; overload;
    procedure ShowModal(const ResultProc: TProc<TModalResult>); overload;

Обращение к первому методу на Android вызывает исключение, а второй предлагается использовать следующим образом.
Вместо такого кода:

var
  dlg: TForm1;
begin
  dlg := TForm1.Create(nil);
  try
    // select current value, if avaialble in the list
    dlg.ListBox1.ItemIndex := dlg.ListBox1.Items.IndexOf(edit1.Text);
    if dlg.ShowModal = mrOK then
      // if OK was pressed and an item is selected, pick it
      if dlg.ListBox1.ItemIndex >= 0 then
        edit1.Text := dlg.ListBox1.Items [dlg.ListBox1.ItemIndex];
  finally
    dlg.Free;
  end;
end;

нужно писать такой:

var
  dlg: TForm1;
begin
  dlg := TForm1.Create(nil);
  // select current value, if avaialble in the list
  dlg.ListBox1.ItemIndex := dlg.ListBox1.Items.IndexOf(Edit1.Text);
  dlg.ShowModal(
    procedure(ModalResult: TModalResult)
    begin
      if ModalResult = mrOK then
      // if OK was pressed and an item is selected, pick it
        if dlg.ListBox1.ItemIndex >= 0 then
          edit1.Text := dlg.ListBox1.Items [dlg.ListBox1.ItemIndex];
      dlg.DisposeOf;
    end);
end;

Идея ясна и мотивация вполне понятна. Код, который должен выполниться после закрытия формы, перемещается в анонимную процедуру, которая и будет вызвана в нужный момент.

Этот код будет работать на всех платформах. Спасибо, Марко!

Но есть нюанс. Давайте заглянем внутрь новой версии ShowModal:

procedure TCommonCustomForm.ShowModal(const ResultProc: TProc<TModalResult>);
begin
  FResultProc := ResultProc;
  Show;
end;

Такая форма будет одинаково немодальна на всех платформах! То есть, если мы хотим писать кроссплатформенных код, нам предлагают либо усыпать код IFDEF-ами, либо отказаться от использования модальных форм совсем. Как-то это нехорошо.

Не знаю как у вас, но у меня в коде часто встречается что-то такое:

with TForm1.Create(nil) do
try
  ShowModal;
finally
  Free;
end;

С учетом андроида этот код можно переписать следующим образом:

var
  Frm: TForm1;
begin
  Frm := TForm1.Create(nil);
  ShowModal(procedure (Res: TModalResult) begin Frm.DisposeOf; end);
end;

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

var
  Frm: TForm1;
begin
  Frm := TForm1.Create(nil);
  {$IFDEF ANDROID}
    Frm.ShowModal(procedure (Res: TModalResult) begin end);
  {$ELSE}
    try
      Frm.ShowModal;
    finally
      Frm.Free;
    end;
  {$ENDIF}
end;

Этот код обеспечит модальность формы в обычных случаях и «всевдомодальность» на андроиде. Но как-то это не очень красиво. Не люблю применять IFDEF-ы по пустякам. Таких мест в коде может быть много.

Подведу итог. В связи с вышеизложенным у меня есть два риторических вопроса.

1) Почему нельзя было новый ShowModal реализовать хотя бы так:

procedure TCommonCustomForm.ShowModal(const ResultProc: TProc<TModalResult>);
begin
  {$IFDEF ANDROID}
    FResultProc := ResultProc;
    Show;
  {$ELSE}
    ResultProc(ShowModal);
  {$ENDIF}
end;

?

2) Если такой подход все-таки чем-то не устраивает, то зачем было называть новый метод ShowModal, ведь он с модальными формами никак не связан?

Awaitable-значения в Delphi

В последнее время я исследую возможности выполнения параллельных и асинхронных операций в Delphi. В качестве одного из результатов родился класс TAwaitable. Код нарочито простой. С одной стороны хочется продемонстрировать идею, хотя она в целом очевидна, с другой стороны — получить свою порцию конструктивной критики. Я довольно мало сталкивался с многопоточностью в Delphi, так что будет здорово, если кто-то укажет на недочеты в реализации, если они есть.

Собственно, для большей наглядности сначала пример использования, а затем код самого класса.

procedure TForm1.Button1Click(Sender: TObject);
var
  WaitFor2, WaitFor3: IAwaitable<Integer>;
  Log: string;
begin
  Memo1.Lines.Add(DateTimeToStr(Now) + ' Start testing...');

  WaitFor2 := TAwaitable<Integer>.Create(
    function : Integer begin
      Sleep(2000);
      Result := 2;
    end);

  WaitFor3 := TAwaitable<Integer>.Create(
    function : Integer begin
      Sleep(3000);
      Result := 3;
    end);

  Memo1.Lines.Add(DateTimeToStr(Now) + ' One more action');
  Log := IntToStr(WaitFor2.Value + WaitFor3.Value);
  Memo1.Lines.Add(DateTimeToStr(Now) + ' And finally the result is... ' + Log);
  Log := IntToStr(WaitFor2.Value + WaitFor3.Value);
  Memo1.Lines.Add(DateTimeToStr(Now) + ' Cached result... ' + Log);
end;

Здесь запускаются в параллельное выполнение две функции. Одна выполняется 2 секунды, другая — 3. И в одном случае результат вычислений — 2, а в другом соответственно — 3. В качестве итога нам нужна сумма двух этих значений. Последовательное вычисление заняло бы, очевидно, 5 секунд. Но данный код выполняется за 3 секунды, благодаря тому, что он выполняется параллельно.

19.11.2013 23:33:04 Start testing...
19.11.2013 23:33:04 One more action
19.11.2013 23:33:07 And finally the result is... 5
19.11.2013 23:33:07 Cached result... 5

Повторное обращение к значению TAwaitable.Value выполняется быстрее, так как вычисления уже завершены.

Код самого класса довольно прост.

unit Awaitable;

interface

uses
  System.Classes;

type
  TAwaitableProc<T> = reference to function : T;

  IAwaitable<T> = interface
    function Value: T;
  end;

  TAwaitable<T> = class(TInterfacedObject, IAwaitable<T>)
  private
    FThread: TThread;
    FValue: T;
  public
    constructor Create(AwaitableProc: TAwaitableProc<T>); reintroduce;
    destructor Destroy; override;

    function Value: T;
  end;

implementation

{ TAwaitable<T> }

constructor TAwaitable<T>.Create(AwaitableProc: TAwaitableProc<T>);
begin
  inherited Create;

  FThread := TThread.CreateAnonymousThread(
    procedure begin
      FValue := AwaitableProc;
    end);

  FThread.FreeOnTerminate := False;
  FThread.Start;
end;

destructor TAwaitable<T>.Destroy;
begin
  FThread.Free;
  inherited;
end;

function TAwaitable<T>.Value: T;
begin
  FThread.WaitFor;
  Result := FValue;
end;

end.

Ну вот как-то так. Этот класс есть куда функционально развивать, но пока не хочется заходить слишком далеко.

update
День не прошел зря, я понял свой недочет. Вместо критических секций лучше использовать уже имеющийся в классе TThread метод WaitFor. Поэтому я немного изменил код.

Using TCanvas in Delphi for Android

Drawing on TCanvas in Delphi XE5 for Android turned out to have some special aspects which at first left me in doubt and I want to share my experience.

Let’s draw some parallel lines.

Here I’d like to digress and notice that on Windows Stroke.Kind value is bkSolid by default, but on Android it’s bkNone. Thus, if you haven’t defined Stroke.Kind value, these lines will be visible on Windows, but will not on Android. I have no idea why they chose this approach.

procedure TForm2.FormPaint(Sender: TObject; Canvas: TCanvas;
  const ARect: TRectF);
var
  I: Integer;
begin
  if Canvas.BeginScene then
    try
      Canvas.Stroke.Thickness := 1.5;
      Canvas.Stroke.Kind := TBrushKind.bkSolid;
      Canvas.Fill.Color := TAlphaColorRec.Black;
      Canvas.Fill.Kind := TBrushKind.bkSolid;

      for I := 1 to 9 do
        Canvas.DrawLine(PointF(50 + I * 25, 0), PointF(50 + I * 25, ClientHeight), 1);
    finally
      Canvas.EndScene;
    end;
end;

That’s it:

Obviously, some lines are thicker than others. On Windows the same code works just perfectly.

The reason is that unlike it does on Windows, the logical pixel on Android is not always equal to physical pixel. And if a line appears to be «between» physical pixels, it has to be blurred on neighboring pixels. It is a trade-off between accuracy and quality of rendering.

If we still want to draw equal lines, we could move them by the half of their thikness to ensure getting the appropriate physical pixels.

That’s how TLine and its ancestor TShape solve the problem:

function TShape.GetShapeRect: TRectF;
begin
  Result := LocalRect;
  if FStroke.Kind <> TBrushKind.bkNone then
    InflateRect(Result, -(FStroke.Thickness / 2), -(FStroke.Thickness / 2));
end;

procedure TLine.Paint;
begin
  case FLineType of
    TLineType.ltTop:
      Canvas.DrawLine(GetShapeRect.TopLeft, PointF(GetShapeRect.Right, GetShapeRect.Top),
        AbsoluteOpacity, FStroke);
    TLineType.ltLeft:
      Canvas.DrawLine(GetShapeRect.TopLeft, PointF(GetShapeRect.Left, GetShapeRect.Bottom),
        AbsoluteOpacity, FStroke);
  else
    Canvas.DrawLine(GetShapeRect.TopLeft, GetShapeRect.BottomRight, AbsoluteOpacity, FStroke);
  end;
end;

Making the appropriate changes we can draw equal lines too:

procedure TForm2.FormPaint(Sender: TObject; Canvas: TCanvas;
  const ARect: TRectF);
var
  I: Integer;
begin
  if Canvas.BeginScene then
    try
      Canvas.Stroke.Thickness := 1.5;
      Canvas.Stroke.Kind := TBrushKind.bkSolid;
      Canvas.Fill.Color := TAlphaColorRec.Black;
      Canvas.Fill.Kind := TBrushKind.bkSolid;

      for I := 1 to 9 do
      begin
        Canvas.DrawLine(PointF(50 + I * 25 - (Canvas.Stroke.Thickness / 2), 0),
          PointF(50 + I * 25 - (Canvas.Stroke.Thickness / 2), ClientHeight), 1);
      end;
    finally
      Canvas.EndScene;
    end;
end;

The result is:

Much better :)

This can’t be done automatically: in this case it will «jump» during an animation. But anyways I’d like to have some flag to choose between accuracy and quality. It’s quite boring to do this calculation manually.

update

Alysson Cunha suggests one more approach:

function TForm2.RoundLogicPointsToMatchPixel(const LogicPoints: Single;
  const AtLeastOnePixel: Boolean = False): Single;
var
  ws: IFMXWindowService;
  ScreenScale, Pixels: Single;
begin
  ws := TPlatformServices.Current.GetPlatformService(IFMXWindowService) as IFMXWindowService;
  ScreenScale := ws.GetWindowScale(Self);

  // Maybe you will want to use Ceil or Trunc instead of Round
  Pixels := Round(LogicPoints * ScreenScale);

  if (Pixels < 1) and (AtLeastOnePixel) then
    Pixels := 1.0;

  Result := Pixels / ScreenScale;
end;

procedure TForm2.FormPaint(Sender: TObject; Canvas: TCanvas;
  const ARect: TRectF);
var
  I: Integer;
begin
  if Canvas.BeginScene then
    try
      Canvas.Stroke.Thickness := RoundLogicPointsToMatchPixel(1.0, True);
      Canvas.Stroke.Kind := TBrushKind.bkSolid;
      Canvas.Fill.Color := TAlphaColorRec.Black;
      Canvas.Fill.Kind := TBrushKind.bkSolid;

      for I := 1 to 9 do
        Canvas.DrawLine(PointF(RoundLogicPointsToMatchPixel(50 + I * 25), 0),
          PointF(RoundLogicPointsToMatchPixel(50 + I * 25), ClientHeight), 1);
    finally
      Canvas.EndScene;
    end;
end;

Работа с TCanvas в Delphi под Android

Рисование на TCanvas в Delphi XE5 под Android как оказалось имеет некоторые особенности, которые по-началу ввели меня в легкое замешательство и которыми хотелось бы поделиться.

Нарисуем несколько параллельных линий.

Здесь хотелось бы отвлечься и заметить, что под Windows значение Stroke.Kind по умолчанию равно bkSolid, а под Android — bkNone. То есть, если явно не установить значение Stroke.Kind, то линии будут видны под Windows, но не под Android. Не знаю, чем такой подход может быть вызван.

procedure TForm2.FormPaint(Sender: TObject; Canvas: TCanvas;
  const ARect: TRectF);
var
  I: Integer;
begin
  if Canvas.BeginScene then
    try
      Canvas.Stroke.Thickness := 1.5;
      Canvas.Stroke.Kind := TBrushKind.bkSolid;
      Canvas.Fill.Color := TAlphaColorRec.Black;
      Canvas.Fill.Kind := TBrushKind.bkSolid;

      for I := 1 to 9 do
        Canvas.DrawLine(PointF(50 + I * 25, 0), PointF(50 + I * 25, ClientHeight), 1);
    finally
      Canvas.EndScene;
    end;
end;

Вот, что у меня получилось:

Очевидно, некоторые линии получились толще других. Тот же код под Windows работает идеально.

Причина этого в том, что в отличие от Windows, на Android логический пиксел не всегда совпадает с физическим. И линия, попавшая между физических пикселей «размывается» на соседние, чтобы быть видимой. Таким образом достигается компромисс между точностью и качеством отрисовки.

Если все же нужно нарисовать одинаковые линии, то можно их сдвинуть на половину толщины, чтобы гарантировать их попадание в физические пиксели.

Именно так решает проблему компонент TLine и его предок TShape:

function TShape.GetShapeRect: TRectF;
begin
  Result := LocalRect;
  if FStroke.Kind <> TBrushKind.bkNone then
    InflateRect(Result, -(FStroke.Thickness / 2), -(FStroke.Thickness / 2));
end;

procedure TLine.Paint;
begin
  case FLineType of
    TLineType.ltTop:
      Canvas.DrawLine(GetShapeRect.TopLeft, PointF(GetShapeRect.Right, GetShapeRect.Top),
        AbsoluteOpacity, FStroke);
    TLineType.ltLeft:
      Canvas.DrawLine(GetShapeRect.TopLeft, PointF(GetShapeRect.Left, GetShapeRect.Bottom),
        AbsoluteOpacity, FStroke);
  else
    Canvas.DrawLine(GetShapeRect.TopLeft, GetShapeRect.BottomRight, AbsoluteOpacity, FStroke);
  end;
end;

Изменив код соответствующим образом, можно добиться одинаковости линий:

procedure TForm2.FormPaint(Sender: TObject; Canvas: TCanvas;
  const ARect: TRectF);
var
  I: Integer;
begin
  if Canvas.BeginScene then
    try
      Canvas.Stroke.Thickness := 1.5;
      Canvas.Stroke.Kind := TBrushKind.bkSolid;
      Canvas.Fill.Color := TAlphaColorRec.Black;
      Canvas.Fill.Kind := TBrushKind.bkSolid;

      for I := 1 to 9 do
      begin
        Canvas.DrawLine(PointF(50 + I * 25 - (Canvas.Stroke.Thickness / 2), 0),
          PointF(50 + I * 25 - (Canvas.Stroke.Thickness / 2), ClientHeight), 1);
      end;
    finally
      Canvas.EndScene;
    end;
end;

Результат:

Намного симпатичнее :)

Делать такой сдвиг всегда автоматически нельзя: в таком случае неточность координат приведет к скачкам при анимации. Но все равно хотелось бы видеть какой-то флаг, позволяющий переложить выбор между красотой и точностью на FireMonkey. Довольно утомительно постоянно это делать самостоятельно.

update

Alysson Cunha предложил еще один подход к решению проблемы:

function TForm2.RoundLogicPointsToMatchPixel(const LogicPoints: Single;
  const AtLeastOnePixel: Boolean = False): Single;
var
  ws: IFMXWindowService;
  ScreenScale, Pixels: Single;
begin
  ws := TPlatformServices.Current.GetPlatformService(IFMXWindowService) as IFMXWindowService;
  ScreenScale := ws.GetWindowScale(Self);

  // Maybe you will want to use Ceil or Trunc instead of Round
  Pixels := Round(LogicPoints * ScreenScale);

  if (Pixels < 1) and (AtLeastOnePixel) then
    Pixels := 1.0;

  Result := Pixels / ScreenScale;
end;

procedure TForm2.FormPaint(Sender: TObject; Canvas: TCanvas;
  const ARect: TRectF);
var
  I: Integer;
begin
  if Canvas.BeginScene then
    try
      Canvas.Stroke.Thickness := RoundLogicPointsToMatchPixel(1.0, True);
      Canvas.Stroke.Kind := TBrushKind.bkSolid;
      Canvas.Fill.Color := TAlphaColorRec.Black;
      Canvas.Fill.Kind := TBrushKind.bkSolid;

      for I := 1 to 9 do
        Canvas.DrawLine(PointF(RoundLogicPointsToMatchPixel(50 + I * 25), 0),
          PointF(RoundLogicPointsToMatchPixel(50 + I * 25), ClientHeight), 1);
    finally
      Canvas.EndScene;
    end;
end;

Дженерики: ограничение «constructor»

Наш японский коллега Jun Hosokawa заметил интересную особенность ограничения «constructor» в дженериках Delphi.

type
  TConstructorConstraint<T: constructor> = class
  end;

В качестве вступления цитата из справки:

This means that the actual argument type must be a class that defines a default constructor (a public parameterless constructor), so that methods within the generic type may construct instances of the argument type using the argument type’s default constructor, without knowing anything about the argument type itself (no minimum base type requirements).

То есть, согласно документации это ограничение позволяет убедиться, что аргумент дженерика — это класс, который имеет публичный конструктор без параметров, благодаря чему дженерик сможет создать экземпляр соответствующего типа, ничего о нем не зная. Здравый смысл может подсказывать нам, что речь только о классах, но на практике все оказывается не так просто. И встретив на просторах интернета высказывания о том, что это при использовании этого ограничения в качестве аргумента все-таки можно передавать не только классы, Jun решил проверить как такое возможно.

unit Unit1;

interface

type
  // нет конструктора без параметров
  TBar = class
  public
    constructor Create(const iDummy: Integer); reintroduce;
  end;

  procedure Test;

implementation

uses
  System.Rtti;

type
  TConstructorConstraint<T: constructor> = class
  private
    FValue: T;
  public
    constructor Create; reintroduce;
    function ToString: string; override; 
  end; 

  // конструктор без параметров
  TFoo = class
  public
    constructor Create; reintroduce;
  end;

  //динамический массив
  TStringDynArray = array of string; 

  // перечислимый тип
  TFactor = (Windows, MacOSX, Android, iOS, WindowsPhone);

  // множество
  TSet = set of TFactor;


{ TConstructorConstraint<T> }

constructor TConstructorConstraint<T>.Create;
begin
  inherited Create;

  // вызываем конструктор, не зная ничего о типе T !!!
  FValue := T.Create;
end;

// вывод информации о типе T
function TConstructorConstraint<T>.ToString: string;
var
  Rtti: TRttiContext;
  Field: TRttiField;
  FieldType: TRttiType;
begin
  Result := ''; 

  Rtti := TRttiContext.Create;
  try
    Field := Rtti.GetType(ClassInfo).GetField('FValue');
    FieldType := Field.FieldType;

    Result := Field.Name + ': ' + FieldType.ToString + ';'; 

    if (FieldType.IsPublicType) then
      Result := Result + ' Public;';

    if (FieldType.IsManaged) then
      Result := Result + ' Managed;';

    if (FieldType.IsInstance) then
      Result := Result + ' Instance;';

    if (FieldType.IsOrdinal) then
      Result := Result + ' Ordinal;';

    // record не может использоваться с ограничением "constructor" и здесь не появится
    if (FieldType.IsRecord) then
      Result := Result + ' Record;';

    if (FieldType.IsSet) then
      Result := Result + ' Set;';
  finally
    Rtti.Free;
  end;
end;

{ TFoo }

constructor TFoo.Create;
begin
  inherited Create;

  Writeln('TFoo Created !');
end;

{ TBar }

constructor TBar.Create(const iDummy: Integer);
begin
  inherited Create;

  // у этого конструктора есть параметры, он не сработает
  Writeln('TBar Created !');
end;

procedure Test;
var
  Foo: TConstructorConstraint<TFoo>;
  Bar: TConstructorConstraint<TBar>;
  Str: TConstructorConstraint<string>;
  Int: TConstructorConstraint<Integer>; 
  Ary: TConstructorConstraint<TStringDynArray>;
  Sets: TConstructorConstraint<TSet>;
  Factor: TConstructorConstraint<TFactor>;
begin
  Foo := nil;
  Bar := nil;
  Str := nil;
  Int := nil;
  Ary := nil;
  Sets := nil;
  Factor := nil;
  try
    Foo := TConstructorConstraint<TFoo>.Create;
    Bar := TConstructorConstraint<TBar>.Create;
    Str := TConstructorConstraint<string>.Create;
    Int := TConstructorConstraint<Integer>.Create;
    Ary := TConstructorConstraint<TStringDynArray>.Create;
    Sets := TConstructorConstraint<TSet>.Create;
    Factor := TConstructorConstraint<TFactor>.Create;

    WriteLn(Foo.ToString);
    WriteLn(Bar.ToString);
    WriteLn(Str.ToString);
    WriteLn(Int.ToString);
    WriteLn(Ary.ToString);
    WriteLn(Sets.ToString);
    WriteLn(Factor.ToString);
    
    Readln;
  finally
    Factor.Free;
    Sets.Free;
    Ary.Free;
    Int.Free;
    Str.Free;
    Bar.Free;
    Foo.Free;
  end;
end;

end.

Тип TFactor был объявлен, но автор не добавил его в тесты (видимо, забыл). Я это поправил.

Программа очень простая и всего-лишь выводит информацию о типе аргумента (спасибо, RTTI). Ниже результат её работы:

TFoo Created !
FValue: TFoo; Instance;
FValue: TBar; Public; Instance;
FValue: string; Public; Manged;
FValue: Integer; Public; Ordinal;
FValue: TStringDynArray; Managed;
FValue: TSet; Set;
FValue: TFactor; Ordinal;

И это удивительно, но это работает. Мы видим информацию о типах. Интересно, что TBar, в отличии от TFoo — это public type. Разница вызвана тем, что они объявлены в разных секциях модуля (interface и implementation). А перечислимый тип превратился в ordinal, что в принципе логически верно.

Но самое интересное то, что все это вообще работает, хотя у многих из этих типов совсем нет конструктора!

Если посмотреть на ассемблерный код, то видно, что здесь имеет место compiler magic.

// TFoo
Project1.dpr.28: FValue := T.Create;
00407D7A B201             mov dl,$01
00407D7C A158724000       mov eax,[$00407258]
00407D81 E85AF6FFFF       call TFoo.Create      // код инициализации
00407D86 8B55FC           mov edx,[ebp-$04]
00407D89 894204           mov [edx+$04],eax

// TBar
Project1.dpr.28: FValue := T.Create;
00407DCE B201             mov dl,$01
00407DD0 A114734000       mov eax,[$00407314]
00407DD5 E8BACDFFFF       call TObject.Create   // код инициализации
00407DDA 8B55FC           mov edx,[ebp-$04]
00407DDD 894204           mov [edx+$04],eax

// string
Project1.dpr.28: FValue := T.Create;
00407E22 8B45FC           mov eax,[ebp-$04]
00407E25 83C004           add eax,$04
00407E28 E843DDFFFF       call @UStrClr         // код инициализации

// Integer
Project1.dpr.28: FValue := T.Create;
00407E6E 8B45FC           mov eax,[ebp-$04]
00407E71 33D2             xor edx,edx           // код инициализации
00407E73 895004           mov [eax+$04],edx

// динамический массив
Unit1.pas.50: FValue := T.Create;
004D50F6 8B45FC           mov eax,[ebp-$04]
004D50F9 83C004           add eax,$04
004D50FC 8B1500394D00     mov edx,[$004d3900]
004D5102 E87D68F3FF       call @DynArrayClear   // код инициализации

// множество
Unit1.pas.50: FValue := T.Create;
004D540E 8B45FC           mov eax,[ebp-$04]
004D5411 8A153C544D00     mov dl,[$004d543c]    // код инициализации?
004D5417 885004           mov [eax+$04],dl

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

Можно прийти к выводу, что нет особого смысла в использовании ограничения «constructor» отдельно от других ограничений, очень уж широко оно трактуется компилятором.

Чтобы ограничить аргументы дженерика именно классами, следует использовать следующий код:

type
  TConstructorConstraint<T: class, constructor> = class
  end;

И интересно, что если убрать из этого кода слово «constructor», то дженерик потеряет возможность создать экземпляр класса, так как именно слово «constructor» позволяет гарантировать, что аргумент будет не просто классом, но так же он будет иметь конструктор по умолчанию. Но ведь в Delphi все классы наследуются от TObject и тем самым всегда имеют публичный конструктор по умолчанию.

В чем же здесь смысл?

P.S. Благодарю сестру за помощью с переводом с японского.

Разработка «стандартных» TAction

Почти все мы сталкиваемся с разработкой довольно сложных пользовательских интерфейсов. Приходится работать с нагромождениями кнопок, тулбаров, меню и всего, что только может придти в голову. К счастью, в Delphi есть компонент TActionList, немного упрощающий жизнь. И он ведь действительно помогает бороться с растущей сложностью интерфейсов.

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

Как говорится, любую проблему можно решить добавлением еще одного уровня абстракции. И такая возможность есть.

Как правило всю огромную кучу действий можно логически разделить на несколько категорий с более-менее похожим поведением. И грех этим не воспользоваться, благо Delphi позволяет создавать собственные типы «стандартных» действий.

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

На примере будет понятно. Представим, что часть действий необходимы для работы с данными и их свойство Enabled жестко привязано к тому, открыт соответствующий датасет или нет. Здесь можно использовать обычные TAction и писать каждый раз SomeAction.Enabled := DataSet.Active в событии OnUpdate, можно придумать что-то более изощренное, а можно просто написать соответствующий «стандартный» наследник TAction.

unit uDataAction;

interface

uses
  System.SysUtils, System.Classes, System.Actions, Vcl.ActnList, Data.DB;

type
  TDbAction = class(TAction)
  private
    FDataSet: TDataSet;
  public
    function Update: Boolean; override;
  published
    property DataSet: TDataSet read FDataSet write FDataSet;
  end;

procedure Register;

implementation

{ TDbAction }

function TDbAction.Update: Boolean;
begin
  if not (csDesigning in ComponentState) then
    self.Enabled := Assigned(FDataSet) and FDataSet.Active;

  Result := inherited Update;
end;

procedure Register;
begin
  RegisterActions('Custom Actions', [TDbAction], nil);
end;

end.

Все до очевидного просто. Этот класс унаследован от обычного TAction, в него добавлено поле DataSet и переопределена функция Update, где значение свойства Enabled по умолчанию привязывается к значению свойства DataSet.Active. Ключевой момент — процедура Register, которая позволит увидеть TDbAction в IDE. Этот модуль нужно добавить в пакет и установить его. И тогда, выбрав в диалоге редактирования TActionList пункт «New Standard Action…», можно увидеть новый TDbAction.

Далее с этим TDbAction можно работать точно так же, как и с привычными действиями, добавляя весь необходимый код в события OnUpdate и OnExecute. С той лишь разницей, что с состоянием датасета оно уже умеет работать.

Пара слов в заключение.

Если на форме используется только «чистый» TAction, то для действий с похожим поведением неизбежно в той или иной степени возникнет дублирование кода. Это влечет за собой самые разные негативные последствия. Данный подход этого недостатка лишен. Общий код вынесен в общий класс, благодаря чему он куда более управляем.

Перспективы мобильной разработки на Delphi

Близится релиз Delphi XE5 (многие уже отмечают) и Embarcadero вслед за поддержкой iOS обещает нам поддержку Android. Мне как гику это кажется интересным, но нужна ли поддержка мобильных устройств рынку? Ведь есть уже немало средств разработки: от Objective C и Java до штук вроде Xamarin. Зачем еще одно?

Вот что я об этом думаю.

Традиционно сложилось, что сильная сторона Delphi — это корпоративные приложения. Различных учетных систем и прочих систем внутренней автоматизации на Delphi всегда было написано куда больше, чем коробочных продуктов. И мне кажется, что как раз в этой среде у мобильных приложений на Delphi есть шанс.

Благодаря инициативам вроде BYOD (Bring Your Own Device) мобильные устройства все больше проникают в корпоративную среду. В России, пожалуй, пока не очень, но в целом корпоративный мир движется в эту сторону. И мы там будем.

Мобильные возможности получают даже такие казалось бы традиционно десктопные продукты как Toad, получивший свое интернет-развитие в виде сервиса MyToad, позволяющего удаленно запускать выполнение SQL-скриптов [а вот DBArtisan от Embarcadero так не умеет ;)].

Многие считают веб-интерфейс панацеей, но это не совсем так. Есть и обратные мнения:

Facebook CEO said that «the biggest mistake we made as a company was betting too much on HTML5 as opposed to «native» code on the iOS and Android platforms».

Не будем останавливаться на веб, вернемся к BYOD. Слово «bring» как бы намекает нам, что устройство можно носить, то есть скорее всего оно мобильное. И вот эта возможность принести и использовать на работе собственное устройство влечет за собой некоторые последствия.

В первую очередь это конечно вопросы безопасности (что будет если пустить какой-то непонятный iPad в корпоративную сеть?). Но мы говорим про Delphi, вопросы безопасности пусть решают системные администраторы, благо крупные вендоры уже предлагают готовые инфраструктурные решения.

Главный касающийся Delphi вопрос — это отсутствие программного обеспечения. Если вы зайдете на Apple Store или Android Market, то увидите огромное количество приложений. Но корпоративным пользователям как правило нужны совсем не Angry Birds и не калькулятор калорий. Тем более что очень часто они работают с какой-то самодельной системой, мобильные клиенты для которой еще только предстоит разработать.

И если сама система была написана на Delphi, то встает выбор — разработать мобильное приложение на Delphi, повторно использовав часть кода и опыт имеющихся разработчиков, или нанять новых/обучить старых разработчиков и написать все с нуля на Objective C или Java. При такой постановке вопроса преимущества Delphi становятся видны. То есть определенная ниша для «мобильной» Delphi все-таки существует.

Так что посмотрим. Ваши мнения?

Delphi и предметно-ориентированные языки

Предметно-ориентированные языки (DSL) в прикладных приложениях используются намного чаще, чем может показаться на первый взгляд. Пожалуй, самый распространенный в повседневной практике случай использования DSL — это разнообразные конфигурационные файлы.

Для описания каких-то сложных конфигураций довольно часто используют XML. Сегодня он де-факто стал чем-то вроде универсального DSL, благодаря обилию готовых средств синтаксического анализа, лексического разбора и всего остального, что необходимо для работы. Но универсальные плюсы влекут за собой и минусы. Давайте посмотрим.

Скажем, поставлена задача разработать программу для светофора. Светофоры в принципе все одинаковые, но вести они себя должны по-разному. Настраивая каждый отдельный светофор, мы можем либо хранить логику его работы в коде, перекомпилируя программу для каждого отдельного светофора (что неприемлемо!), либо вынести эту логику во внешний конфигурационный файл и поручить настройку отдельных светофоров сотрудникам ГИБДД (то есть в общем случае это будут эксперты предметной области, а не программисты).

Представим, как бы мог выглядеть этот конфигурационный файл в формате XML. Пусть конкретный светофор ведет себя следующим образом. 10 секунд горит красный, затем 3 секунды мигает желтый и, наконец, 7 секунд горит зеленый. Проявив немного фантазии, можно представить себе вот такой XML-файл:

<trafficlight>
  <red>10</red>
  <yellow blink="yes">3</yellow>
  <green>7</green>
</trafficlight>

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

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

Но иначе можно.

Что, если записать настройки так?

red 10
yellow 3 blink
green 7

Уже чуть больше ясности. Или даже так, добавим гибкости, чтобы в будущем иметь возможность менять начальное состояние светофора и задавать более сложные режимы работы:

init
  red off
  yellow off
  green off
endinit
loop
  red on
  wait 10
  red off
  yellow on
  wait 1
  yellow off
  wait 1
  yellow on
  wait 1
  yellow off
  green on
  wait 7
  green off
endloop

Мне нравится эта программа. Давайте попробуем её выполнить. Так как у меня дома нет настоящего светофора (а жаль), то программировать придется его простую программную модель.

Скриншот программы TrafficLight

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

Для автоматизации задач синтаксического и лексического анализа текста часто используют утилиты вроде Lex/Yacc. Недавно я писал о попытке реанимировать их версию для Delphi. И здесь они к месту.

Не буду пытаться объять необъятное, на просторах интернета множество руководств по использованию Lex и Yacc. Буду краток. С помощью данных утилит можно сгенерировать код парсера некоего языка по его формальному описанию. Собственно, они позволяют сгенерировать два куска кода: лексер (Lex) и парсер (Yacc). Лексер будет разбивать текст на токены, а парсер собирать эти токены в большую осмысленную программу.

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

Описание токенов в нашем случае весьма однообразное. В языке программирования светофора есть ключевые слова RED, YELLOW, GREEN, ON, OFF, INIT, ENDINIT, LOOP, ENDLOOP, WAIT, а так же числа. Переводы строк и пробелы игнорируются. Ниже содержание файла описания токенов для Lex (как водится, слева регулярное выражение, справа шаблон Delphi-кода для его обработки).

D				[0-9]

%start
%%

  var result : integer;

{D}+(\.{D}+)?([Ee][+-]?{D}+)?	begin
				  val(yytext, yylval.yyCardinal, result);
				  if result=0 then
				    return(NUM)
				  else
				    return(ILLEGAL)
				end;

RED				begin
	   			    return(RED)
				end;

YELLOW				begin
	   			    return(YELLOW)
				end;

GREEN				begin
	   			    return(GREEN)
				end;

INIT				begin
	   			    return(INIT)
				end;

ENDINIT				begin
	   			    return(ENDINIT)
				end;

LOOP				begin
	   			    return(LOOP)
				end;

ENDLOOP				begin
	   			    return(ENDLOOP)
				end;

ON				begin
	   			    return(ON)
				end;

OFF				begin
	   			    return(OFF)
				end;

WAIT				begin
	   			    return(WAIT)
				end;

" "             		;

.				|
\n				;
\r				;

Затем нужно показать Yacc’у, по каким правилам из этих токенов формируется синтаксическое дерево программы (ниже фрагмент конфигурационного файла):

%token <Cardinal> NUM 
%token <TCustomLight> RED YELLOW GREEN
%token <TEventList> INIT ENDINIT LOOP ENDLOOP

%type <TCustomEvent> expr
%type <TCustomLight> light

%token ON OFF
%token WAIT

%token ILLEGAL 		/* illegal token */

%%

input	:  /* empty */
	|  _init _loop		 { yyaccept; }   
	;

_init   : INIT exprs ENDINIT 	 { FInitList.AddRange(FTemporaryList.ToArray); FTemporaryList.Clear; }
        ;

_loop   : LOOP exprs ENDLOOP	 { FLoopList.AddRange(FTemporaryList.ToArray); FTemporaryList.Clear }
        ;

exprs   :  /* empty */
	|  exprs expr 		 { FTemporaryList.Add($2); }
        ;

expr    :  WAIT NUM		 { $$ := TWaitEvent.Create($2); }
	|  light ON		 { $$ := TLightOnEvent.Create($1); }
	|  light OFF		 { $$ := TLightOffEvent.Create($1); }
	;

light   :  RED			 { $$ := FRedLight; }
        |  YELLOW		 { $$ := FYellowLight; }
        |  GREEN		 { $$ := FGreenLight; }
	;

Здесь видно, что есть тип токена light, который олицетворяет собой название конкретной лампочки. Тип expr, представляющий собой отдельное действие над лампой или задержку. А так же списки этих действий (exprs, на которых построены блоки _init и _loop). И опять же — слева описание, справа в скобках шаблон кода. Для программы из примера по этому описанию парсер может сформировать примерно вот такое синтаксическое дерево и обработать его:

Синтаксическое дерево программы (TrafficLight)

На нижнем уровне находятся отдельные команды и их операнды, выше они постепенно объединяются в единое синтаксическое дерево. Проходя дерево «снизу вверх», останавливаясь в вершинах и выполняя код, указанный в шаблонах, парсер формирует результат. В нашем случае результат — это просто два списка команд (INIT и LOOP), эти списки затем используются для управления моделью светофора.

При должной сноровке подобные парсеры можно создавать очень и очень быстро. Не считая конфигурации утилит, в данной программе мною написано всего-лишь порядка 200 строк кода (включая модель светофора и код, который ей управляет). Это довольно мало, то есть разработка небольшого языка не требует больших затрат сил и времени. Хотя, конечно, необходима несколько большая квалификация, чем при работе с XML. Удобство пользователей обычно того стоит :)

Данный пост не претендует на то, чтобы быть полным руководством. Его цель продемонстрировать возможности утилит и относительную простоту разработки небольшого предметно-ориентированного языка, и всем этим, вероятно, заинтересовать тех, кто с этой темой в силу обстоятельств еще не сталкивался.

Ссылка на полный код примера.