Delphi programming blog
Источник: http://teran.karelia.pro/articles/item_4471.html
 

Практика #3. Абстракция

Опубликовано 13.05.2011 г. 00:08

Третья статья по решению поставленной задачи с использованием различных инструментов Delphi.

Какая была у нас задача? Мы редактируем какие то данные на форме. Данные у нас представляются в виде таблицы. Вверху формы например у нас есть выпадющий список для выбора "элемента". Когда мы его выбираем, у нас заполняется соответствующая таблица для вывода данных. Для этого наш класс, представляющий этот "элемент", экспортирует заголовки таблицы и ее размерность. Обращаясь к этому же классу мы можем получать значения ячеек. Все подобные элементы будут унаследованы от одного общего предка. Понятно, что этот предок и будет нам предоставлять весь необходимый функционал. Можно это назвать интерфейсом, но это не совсем корректно. О заполнении таблицы было подробно написано в предыдущей статье. Итак, давайте теперь рассмотрим более подробно наш базовый класс "элемента" данных. На самом деле достаточно большую часть его методов мы уже упомянули, эти методы касались настройки вида таблицы, размера и заголовков. Что еще нам необходимо? Мы должны иметь методы для сохранения или отмены изменений. Также, если мы вдруг переключим элемент и вернемся обратно, нам понадобится метод обновления (вдруг данные уже изменились). Естественно на данном этапе проектирования мы понятия не имеем какова будет реализация этих методов, поэтому они будут абстрактными. Наша секция public класса TCustomDataItem расширяется следующим образом (часть касающаяся таблицы представления уже приводилось в конце статьи №2):
        procedure Save();    virtual; abstract;
        procedure Refresh(); virtual; abstract;
        procedure Cancel();  virtual; abstract;
Очевидно каждый такой конечный класс/элемент должен иметь некоторый заголовок, который мы будем отображать в выпадющем меню, и также ему нужна категория в которой он будет содержаться (набор категорий у нас конечен). Это значит что нам надо реализовать два public-свойства - заголовок и категория. Для их задания в конечных классах мы воспользуемся атрибутами (rtti). Подробный пример использования для этих целей атрибутов можно посмотреть тут. Так что сами переменные члены-класса хранящие эти данные можно объявить в секции private. Нам не требуется чтобы потомки их видели. Так что теперь мы опять расширяем наш класс TCustomDataItem:
    strict private
      var
        FCategory : TDataItemCategory;
        FTitle : string ;
  ... 
     public
        property Title : string read FTitle;
        property Category : TDataItemCategory read FCategory;
Свойства доступны только для чтения. Для описания категории и заголовка мы создаем новый атрибут. Все атрибуты наследуются от класса TCustomAttribute (rtti).
    TItemDescriptionAttribute = class(TCustomAttribute)
      strict private
        FCategory : TDataItemCategory;
        FTitle : string;
      public
        constructor Create(category : TDataItemCategory; aTitle : string);
        property Title : string read FTitle;
        property Category : TDataItemCategory read FCategory;
    end;
Тут кажется все понятно. И тогда описание каждого конечного класса будет начинаться примерно таким образом:
type
    [TItemDescription(dicDemography, 'Рождаемость')]
    TBirthrateDataItem = class(TCustomDBIntDataItem)
.....
Указанные параметры передаются в конструктор атрибута. Атрибуты кстати создаются при вызове метода getAttributes() в rtti. Теперь необходимо связать наш атрибут и класс. Т.е заполнить поля заголовка и категории по данным атрибута. Где это можно сделать? Очевидным путем будет реализация в конструкторе. Итак расширяем наше описание TCustomDataItem конструктором, и деструктором. И не забываем директиву override. Кстати помните про создание заголовков строк и столбцов таблицы? Конечный класс таки должен где то их создавать, для этого мы предоставим ему protected процедуру InitHeaders, которую также будет добросовестно вызывать при создании класса. Итак:
    protected
        procedure InitHeaders(); virtual; abstract;
    public
        constructor Create();
        destructor  Destroy(); override;
Что мы должны сделать при создании? Первое - выполнить родительский конструктор. Вы можете сказать что наш класс унаследован от TObject, конструктор которого пустой, поэтому это не требуется, но я считаю что лучше всегда вызывать родительский конструктор. Возможно когда нибудь предок изменится, и вам не придется редактировать конструктор. Вторым шагом будет вызов процедуры InitHeaders. Здесь наш конечный класс сформирует заголовки строки столбцов. После чего мы добросовестно можем проверить сделал ли он это. Мы же не можем представлять данные если заголовков нет, поэтому в случае если соответствующие переменные не инициализированы, то мы будем вызывать исключение. Когда в конструкторе класса возникает исключительная ситуация, то после этого сразу вызывается деструктор объекта. У этого свойства есть два следствия. Во-первых если в конструкторе происходит исключительная ситуация, то код вида a := TTest.Create() вернет nil. Второе - надо всегда быть осторожным при написании деструктора, в котором память освобождается. Вы можете попытаться уничтожить объект-член-класса который так и не был создан в конструкторе. Или провести какую то операцию, которую нужно было бы проводить только в случае успешного функционирования объекта. Третьим пунктом работы будет получение атрибута и запоминание его свойств. Что получилось:
constructor TCustomDataItem.Create();
var ctx : TRttiContext;
    t : TRttiType;
    a : TCustomAttribute;
begin
    inherited Create();

    InitHeaders();
    if not (assigned(FColHeaders) and assigned(FRowHeaders)) then begin
        raise Exception.Create('Не назначены заголовки столбцов/строк. Следует перекрыть метод InitHeaders.');
    end;

    ctx := TRttiContext.Create();
    try
        t := ctx.GetType(self.ClassType);
        for a in t.GetAttributes() do begin
            if a is TItemDescriptionAttribute then begin
                FCategory := TItemDescriptionAttribute(a).category;
                FTitle    := TItemDescriptionAttribute(a).title;
            end;
        end;
    finally
        ctx.Free();
    end;
end;
Так же используя исключения мы можем проконтролировать, что конечному классу действительно был назначен атрибут с описанием. Что касается деструктора. Поскольку фактически в конструкторе мы ничего не создавали, то и освобождать нам нечего. Иногда полезно руководствоваться правилом - не я создавал - не мне и уничтожать. Но в принципе мы можем сократить свою работу в конечных классах. Что касается конструкторов, то обычно всегда вызывается родительский, потом проходит своя работа. В деструкторах наоборот, сначала делаем свои дела, затем вызываем родительский последним действием. Поэтому давайте мы избавим конечные классы от необходимости удалять заголовки строк и столбцов таблицы - FRow/ColHeaders и удалим их, если они еще есть.
destructor TCustomDataItem.Destroy();
begin
    FreeAndNil(FColHeaders);
    FreeAndNil(FRowHeaders);

    inherited;
end;
Конечно же осталось самое главное - нам необходимо получать значения для ячеек таблицы. Как мы условились в самом начале значение у нас описывается не просто числом, но еще владельцем (пользователем) и источником (идентификтором). Следовательно значение будет представлять собой структуру с тремя полями. Назовем ее TCellValue. Опять же, поскольку мы пока что не знаем как получить значение ячейки, нам нужны абстрактные методы ее получения. И еще один фактор: нам весьма пригодится такой параметр как "действие" над ячейкой. Когда мы выводим данные в таблицу, мы должны знать каково состояние этой ячейки. Быть может она в исходном состоянии, может добавлена, удалена, или же отредактирована. Во-первых эту информацию мы можем использовать для раскраски ячеек цветом. Во вторых нам понадобится эта информация когда мы будем сохранять ячейки. Теперь давайте к реализации. Начнем с конца - множество состояний ячейки.
    TValueAction = (vaNone, vaInsert, vaUpdate, vaDelete);
Теперь расширим класс TCustomDataItem. Нам необходимо два свойства - получить значение и состояние, и три соответствующих метода (3 ибо состояние можно только считывать). Обратите внимание, что параметры aCol, aRow это индексы столбцов в таблице, и отсчитываться они будут в рабочей области таблицы (т.е aCol = grid.col - fixedCols).
    protected
        function  getCellValue(aCol, aRow : integer): TCellValue ; virtual; abstract;
        procedure setCellValue(aCol, aRow : integer; data : TCellValue); virtual; abstract;
        function  getCellState(aCol, aRow : integer): TValueAction; virtual; abstract;
    public
        property Cells[aCol,aRow : integer] : TCellValue read getCellValue write setCellValue;
        property CellState[aCol,aRow : integer] : TValueAction read getCellState;
Все, описание класса TCustomDataItem закончено. С точки зрения формы редактирования мы получаем от него все что надо: название, категорию, размерность таблицы и ее заголовки, значения ячеек и их состояние. Чтобы взаимодействовать с интерфейсом этого достаточно. Но вернемся к значению ячеек. Мы решили что они будут представимы с помощью структуры. Нам нужно три поля - значение, владелец, источник. Причем значение на данном этапе это строка. На уровне работы с таблицей мы же оперируем строками. Незачем задумываться о типе данных раньше времени. Что может быть полезным здесь? Раз уж мы говорим что это значение, то наверняка нам будет удобно сравнивать значения. Допустим то что было в таблице, и то что мы отредактировали. Дак давайте переопределим сразу операции сравнения, вернее эквивалентности.
    TCellValue = record
        value : string;
        source : integer;
        owner  : integer;
        class operator Equal(r,l : TCellValue) : boolean;
        class operator NotEqual(r,l : TCellValue) : boolean;
    end;
Про перегрузку операторов уже была статья в этом блоге. Чтобы сравнить две записи нам понадобится всего навсего сравнить все три значения.
class operator TCellValue.Equal(r, l: TCellValue): boolean;
begin
    result := (r.value  = l.value)  and
              (r.source = l.source) and
              (r.owner  = l.owner);
end;

class operator TCellValue.NotEqual(r, l: TCellValue): boolean;
begin
    result := not (l = r);
end;
На этом уровень взаимодействия графического интерфейса и верхнего уровня абстракции завершен. Все что нужно чтобы абстрактно редактировать данные у нас есть.Поэтому сейчас мы можем уже полностью реализовать код формы редактирования. Окей, каким будет обработчик нажатия кнопки Сохранить и отмена? Замечу, мы предполагаем что там будет лишь вызываться либо метод Save либо Cancel. Давайте сделаем для этих двух кнопок общий обработчик. Различать мы их будет по полю tag. Для красоты даже добавим вложенный тип данных:
procedure TDataEditForm.ActionButtonClick(Sender: TObject);
type TItemAction = (iaSave, iaCancel);
var action : TItemAction;
begin
    action := TItemAction( TComponent(sender).Tag);

    case action of
        iaSave :     FCurrentItem.Save();
        iaCancel :   FCurrentItem.Cancel();
    end;

    UpdateGridState();
end;
Тут функция UpdateGridState обновляет таблицу, ее мы уже описывали в прошлой статье. Так же было бы не плохо заключить оператор case в блок try и обновление таблицы вызывать в finally, если при сохранении произойдет сбой - раскраска грида будет адекватной. При выборе ячейки в таблице (при смене активной ячейки) нам понадобится сменить также элементы владельца и источника.
procedure TDataEditForm.DataGridSelectCell(Sender: TObject; ACol, ARow: Integer; var CanSelect: Boolean);
var cv : TCellValue;
begin
    canSelect := true;
    cv := FCurrentItem.cells[ ItemCol[aCol], itemRow[aRow] ];
    try
        OwnerId  := cv.owner;
        SourceId := cv.source;
    except
        on e : Exception do
            MessageDlg('DataGridSelectCell : ' + e.Message, mtError, [mbOk], 0);
    end;
end;
тут нужно некоторое пояснение. на входе мы имеем aCol,aRow относительно грида, с учетом фиксированных столбцов. Взаимодействуя с TCustomDataItem мы хотим передавать туда индекс "рабочих" столбцов, т.е за вычетом fixedCols. Этим вычетанием у нас занимаются свойства добавленные ItemCol & itemRow формы. Т.е когда мы обращаемся к ItemCol[aCol] то получаем aCol - fixedCols. Далее, чтобы не вдаваться в подробности как именно у нас будут представлены элементы выбора владельца и источника мы добавляем два свойства к форме OwnerId & SourceID. их set/get методы будут скрывать от нас эти действия по взаимодействию с интерфейсом. Так что теперь, если для владельца или источника мы захотим поставить не comboBox а RadioGroup то изменение кода понадобится только в одном месте. Теперь последний шаг - редактирование значения. Опять же обратно - редактирование это изменение одного из трех параметров. Так что в обработчиках событий измения грида, или контролов отвечающих за владельца и источник мы будем вызывать один метод - setCellValue, куда будем передавать sender, чтобы на всякий случай знать, кто вызывал событие, и соответственно что за свойство ячейки изменилось + номер ячеки.
procedure TDataEditForm.SetCellValue(Sender : TObject; aCol, aRow : integer);
var cv : TCellValue;
    oldValue : TCellValue;
begin
    oldValue  := FCurrentItem.cells[ itemCol[aCol], itemRow[aRow] ];

    cv.value  := trim(dataGrid.Cells[aCol, aRow]);
    cv.source := SourceID;
    cv.owner  := OwnerID;

    if cv <> oldValue then begin
        FCurrentItem.cells[itemCol[aCol], itemRow[aRow]] := cv;
    end;

    DataGrid.Colors[aCol, aRow] := cellColors[ FCurrentItem.CellState[itemCol[aCol], itemRow[aRow]] ];
end;
Что мы тут сдалаем? во первых получим текущее значение которое есть. Далее сформируем новую запись, собрав введенное в грид значение, владельца, источник. Вот теперь нам и пригодятся переопределенные операции сравнения! Если старое значениие и новое не равны между собой, то мы записываем новое. После чего обновляем цвет ячейки, из константного массива cellColors : array[TValueAction] of TColor = (....); На этом реализация интерфейса и базового абстрактного класса завершена (пару мелочей в методах формы я опустил, но наверное потом выложу исходник). Как итог: в этой части мы выделили те абстрактные методы и свойства которые необходимы для работы с графическим интерфейсом. При этом мы использовали некоторые механизмы RTTI и переопределение операторов. Теперь задача сводится к конечной реализации. Однако на самом деле впереди у нас еще целых два промежуточных слоя, пока мы дойдем до конечного класса TBirhtrateDataItem. Содержание:
  1. Практика #1. Постановка задачи.
  2. Практика #2. Настройка таблицы.
  3. Практика #3. Абстракция
  4. Практика #4. Обобщения и кэширование.
  5. Практика #5. Конечная реализация
Метки:  ООП 

Комментарии

Нет комментариев
- Имя
- e-mail*
- Сайт
вы можете использовать теги [i],[b],[code],[quote]
Дополнительно