Паттерн «Стратегия» в D. Строим простое семейство алгоритмов для рисования графических примитивов

Довольно часто в своей практике я использую D Form Library 2 (DFL2) для своих экспериментов с математической графикой и рисованием, но бывает так, что приходиться пользоваться другими инструментами для тех же самых задач. Одним из таких инструментов является библиотека dlib, которая служит для работы с изображениям и которая способна работать в среде Linux, что делает ее одним из прекраснейших средств для математических экспериментов с графикой.

Однако не все так гладко и есть ряд проблем, которые хоть и не отравляют работу с графическими объектами, но делают ее муторной, заставляя каждый раз делать практически одни и те же операции. Сведение математической графики к повседневной рутине – это наименьшая проблема из всех возникающих, но она одна из ведущих причин появления остальных проблем: учащения ошибок, необходимость постоянной сверки с документацией (DFL2 значительно богаче по возможностям построения графических объектов, нежели dlib, но значительно уступает последней по манипуляциям с самим изображением) и т.д.

Следующей проблемой является огромная разность (разность в том смысле, в каком понимается английское слово «difference») между двумя библиотеками, а так порой хочется, чтобы этой разницы не было! Печальный факт, но dlib значительно проигрывает по количеству встроенных примитивов: в D Form Library вы можете рисовать множество самых разнообразных объектов практически одной командой и единственной вашей заботой будет согласование параметров для выбранных примитивов и написанной процедурой (т.е. методом или функцией) отрисовки. С другой стороны, dlib работает и в среде Windows, и в среде Linux, чего не сказать про DFL2, которая, кстати, работает только под Windows или Wine.

В совокупности, казалось бы, остается либо идти на компромисс и выбирать только одну из библиотек (например, выбрав dlib вы сохраняете многоплатформенность, но тратите некоторое время на написание собственных процедур рисования), либо подготовить некоторые шаблоны для свободного перехода в коде от DFL2 к dlib или наоборот (в D есть замечательные вещи для этого: static if, version, debug и некоторые другие), но есть и иной выход из положения.

Давайте представим, что вместо контейнеров Graphics (в DFL2) и SuperImage (в dlib), содержащих пользовательскую (да и не только) графику, есть некоторый абстрактный контейнер Surface, представляющий собой двумерную поверхность (плоскость) для рисования и содержащий в себе методы отрисовки самых разных примитивов. Для того, чтобы контейнер Surface (контейнер не в смысле структуры данных, а в абстрактном смысле, как нечто, способное содержать в себе различные элементы, в том числе и элементы наподобие самого себя) смог справиться с отрисовкой как в окне DFL, так и в файле изображения dlib, мы применим инкапсуляцию поведения для отрисовки: создадим набор классов, отвечающих за рисование конкретного примитива, и набор интерфейсов, соответствующих классам рисования.

Чтобы было возможным рисование на некоторой Surface, для которой мы можем даже не знать конкретный тип (поверхность ли это DFL2, т.е окно формы, или же это поверхность dlib, т.е файл изображения), мы воспользуемся делегированием вызова метода рисования объекту типа интерфейса и принципом подстановки, который гласит, что тип базового класса может быть использован для любого объекта типа производного класса (в D, кстати, есть понятия статического и динамического типа для переменной, содержащейся некий объект), иными словами, экземпляр класса-потомка всегда сможет заменить экземпляр класса-предка; но использовать принцип подстановки мы будем, что называется в обратную сторону – мы будем подменять динамический тип объекта, ответственного за рисование конкретного графического примитива на общий тип для примитивов графики.

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

// интерфейс для точки
interface surfacePoint
{
    // void setColor(ubyte R, ubyte G, ubyte B); // как вариант
    void setColor(RGBColor color);
    void draw(int X, int Y);
}


// интерфейс для обычной ломанной (прямая - частный случай для ломанной)
interface surfaceLine
{
    void setColor(RGBColor color);
    void draw(int[] points...);
} 

Интерфейсы показывают, что класс, которому выпадет участь представлять собой их реализацию, должен будет определить методы, устанавливающие цвет линии или точки и рисующие соответствующий графический объект. В случае метода setColor, который устанавливает цвет графического примитива поверхности, в качестве аргумента используется структура RGBColor, которой нет ни в DFL2, ни в dlib, и которая выглядит примерно так:

// создаем свой тип для представления цвета (так как структуры описывающие цвета на поверхностях разные)
struct RGBColor
{
    ubyte R, G, B;
    
    this(ubyte R, ubyte G, ubyte B)
    {
        this.R = R;
        this.G = G;
        this.B = B;
    }
    
    // перегружаем преобразование нашей структуры в аналогичную из DFL
    Color opCast(T : Color)()
    {
        return Color(R,G,B);
    }
    
    // перегружаем преобразование нашей структуры в аналогичную из DLib
    Color4f opCast(T : Color4f)()
    {
        float r = R / 255.0f;
        float g = G / 255.0f;
        float b = B / 255.0f;
        
        return Color4f(r, g, b);
    }
}

Данная структура потребовалось для того, чтобы не идти на компромиссы между двумя библиотеками, каждая из которых содержит целые наборы структур для описания такого понятия как «цвет». Кроме того, собственное представление цвета нам пригодится для реализации унифицированного (единообразного) типа, который еще будет обладать возможностями приведения к типу цвета DFL (Color) и к типу dlib (Color4f).

Шаг первый сделан – интерфейсы определены и готовы сыграть свою роль в наследовании, в частности, именно они позволят нам породить классы, представляющие собой описание точки и линии в двух графических библиотеках:

  • точка в DFL и dlib:
class dflPoint : surfacePoint
{
private: 
    Graphics graphics;
    Color color;
    
public:
    this(Graphics graphics)
    {
        this.graphics = graphics;
    }
    
    void setColor(RGBColor color)
    {
        this.color = cast(Color) color;
    }
    
    void draw(int X, int Y)
    {
        Pen pen = new Pen(color, 1);
        graphics.drawLine(pen, X, Y, X + 1, Y + 1);
    }
}

// точка в dlib
class dlibPoint : surfacePoint
{
private:
    SuperImage superImage;
    Color4f color;
    
public:
    this(SuperImage superImage)
    {
        this.superImage = superImage;
    }
    
    void setColor(RGBColor color)
    {
        this.color = cast(Color4f) color;
    }
    
    void draw(int X, int Y)
    {
        superImage[X, Y] = color;
    }
}
  • линия в DFL и dlib:
// линия с точки зрения DFL
class dflLine : surfaceLine
{
private:
    Graphics graphics;
    Color color;
    
public:
    this(Graphics graphics, Color color = Color(0,0,0))
    {
        this.graphics = graphics;
    }
    
    void setColor(RGBColor color)
    {
        this.color = cast(Color) color;
    }
    
    void draw(int[] points...)
    {
        Point[] XY;
        Pen pen = new Pen(color, 1);
        
        for (int i = 0; i < points.length; i += 2)
        {
            XY ~= Point(points[i], points[i+1]);
        }
        
        graphics.drawLines(pen, XY);
    }
}

// линия с точки зрения dlib
class dlibLine : surfaceLine
{
private:
    SuperImage superImage;
    Color4f color;
    
    // рисование линии методом DDA, т.к стандартный примитив отсутствует
    void drawDDALine(ref SuperImage simg, Color4f color, int x1, int y1, int x2, int y2)
    {
        import std.algorithm : max;
        import std.math : abs;
        
        int deltaX = abs(x1 - x2);
        int deltaY = abs(y1 - y2);
        int length = max(deltaX, deltaY);
        
        if (length == 0)
        {
            simg[x1, y1] = color;
        }
        
        float dx = cast(float) (x2 - x1) / cast(float) length;
        float dy = cast(float) (y2 - y1) / cast(float) length;
        float x = x1;
        float y = y1;
        
        length++;
        while(length--)
        {
            x += dx;
            y += dy;
            simg[cast(int) x, cast(int) y] = color;
        }
        
    }
    
public:
    this(SuperImage superImage)
    {
        this.superImage = superImage;
    }
    
    void setColor(RGBColor color)
    {
        this.color = cast(Color4f) color;
    }
    
    void draw(int[] points...)
    {
        auto firstX = points[0];
        auto firstY = points[1];
        
        for (int i = 2; i < points.length; i += 2)
        {
            drawDDALine(superImage, color, firstX, firstY, points[i], points[i+1]);
            firstX = points[i];
            firstY = points[i+1];
        }
    }
}

Как видите, сами классы довольно просты и имеют довольно много практически одинаковых фрагментов, что позволяет легко и просто генерировать такие своеобразные «обертки», а при необходимости изменять их содержимое прозрачным образом: например, dlib не имеет реализации рисования даже точки (поэтому приходиться вручную работать с массивом изображения), однако, в классе dlibPoint есть метод draw, правильный вызов которого приведет к построению интересующей нас точки. При этом, интересный момент, если текущий метод рисования не устраивает, то его можно будет подменить на свою, просто определив в классе приватный метод и вызвав его в публичном методе draw: например, вы можете приватный метод drawDDALine заменить на свою реализацию этого метода или вообще исключить этот метод, заменив его на что-то более нужное или интересное лично вам.

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

// статический тип - surfaceLine, динамический тип – dlibLine
surfaceLine line = new dlibLine(simg);
// но все равно вызовется нужный метод draw
line.draw(10, 10, 100, 100);
// теперь динамический тип - dflLine
line = new dflLine(ea.graphics); // ea.graphics - это объект типа Graphics
// та же самая линия, но теперь в окне, а не в файле изображения
line.draw(10, 10, 100, 100); 

Как видите, открывается возможность динамически (по мере надобности) изменять класс экземпляра, при этом оставаясь в рамках статического типа, что позволяет реализовывать семейства алгоритмов (в нашем случае, первое семейство алгоритмов – алгоритмы, работающие в DFL; а второе семейство – алгоритмы, работающие в dlib).

Именно, подобный факт (в совокупности с построенной нами иерархией классов) позволяет приступить теперь к реализации самого главного класса – класса Surface, который выглядит вот так:

// вот тот тип который нам действительно нужен - поверхность
abstract class Surface
{
protected:
    surfaceLine line;
    surfacePoint point;
    RGBColor color;
    
public:
    void setColor(RGBColor color)
    {
        this.color = color;
    }
    
    // делегируем отрисовку конкретному экземпляру, реализующему интерфейс
    void drawPoint(int X, int Y)
    {
        point.setColor(color);
        point.draw(X, Y);
    }
    
    // делегируем отрисовку конкретному экземпляру, реализующему интерфейс
    void drawLine(int[] points...)
    {
        line.setColor(color);
        line.draw(points);
    }
}

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

Поскольку, класс является абстрактным, мы не можем создать его экземпляр, однако, можно получить ряд его потомков, которые будут конкретными (а не абстрактными), что даст возможность реализовать два типа поверхностей: поверхность DFL(dflSurface) и поверхность dlib(dlibSurface). Подобная вещь делается довольно просто: путем присвоения защищенным атрибутам класса некоторых существующих классов внутри конструктора унаследованного от Surface класса:

// поверхность образуемая DFL2
class dflSurface : Surface
{
    this(Graphics graphics)
    {
        point = new dflPoint(graphics);
        line =  new dflLine(graphics);
    }
}


// поверхность образуемая dlib
class dlibSurface : Surface
{
    this(SuperImage superImage)
    {
        point = new dlibPoint(superImage);
        line =  new dlibLine(superImage);
    }
}

В итоге, получается следующая интересная вещь: мы можем объявлять переменную типа Surface и присваивать ей конкретный экземпляр любого из классов, при этом все обращения по методам будут всегда одинаковы, несмотря на то, что dflSurface и dlibSurface – это разные классы (и разные типы). Тип абстрактного базового класса дает нам возможность единообразного обращения к разным типам поверхности (да и вообще, если честно мы даже можем не знать какого она типа!), что легко демонстрирует следующий тестовый код:

// экспериментальная форма DFL2
class MyForm: dfl.form.Form
{
    this()
    {
        initializeMyForm();
    }
    
    // перегружаем отрисовку окна
    override protected void onPaint(PaintEventArgs ea)
    {
        super.onPaint(ea); // вызов отрисовки самого окна
        
        SuperImage simg = image(500, 500); // картинка для dlib
        
        // статический тип - surfaceLine, динамический тип - dlibLine
        surfaceLine line = new dlibLine(simg);
        // но все равно вызовется нужный метод draw
        line.draw(10, 10, 100, 100);
        // теперь динамический тип - dflLine
        line = new dflLine(ea.graphics); // ea.graphics - это объект типа Graphics
        // та же самая линия, но теперь в окне, а не в файле изображения
        line.draw(10, 10, 100, 100);
        
        Surface surface = new dlibSurface(simg); // первая поверхность
        surface.setColor(RGBColor(0,255,0));     // цвет тона (т.е примитивов)
        
        import std.math : sin;
        
        Surface surface2 = new dflSurface(ea.graphics); // вторая поверхность
        surface2.setColor(RGBColor(0,255,0));            
        
        for (float i = -250.0; i < 250; i += 0.1)
        {
            auto X = cast(int) (500 + i);
            auto Y = cast(int) (250 + 250 * sin(i / 20.0));
            
            surface.drawPoint(X, Y); // точка на первой поверхности
            surface2.drawPoint(X, Y); // точка на второй поверхности
        }
        
        simg.savePNG("sample.png"); // сохраняем картинку
        ea.graphics.drawLine(new Pen(Color(245,0,100), 2), 12, 50, 345, 70); // рисуем что-нибудь в окне 
    }
    
    private void initializeMyForm()
    {
        text = "My Form";
        clientSize = dfl.all.Size(496, 474);
        // paint ~= &this.onPaint;
    }
}

void main()
{
    try
    {
        Application.enableVisualStyles();
        Application.run(new MyForm);
    }
    catch(DflThrowable o)
    {
        msgBox(o.toString(), "Фатальный сбой", MsgBoxButtons.OK, MsgBoxIcon.ERROR);
    }
    
}

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

Паттерн Стратегия определяет семейство алгоритмов, инкапсулирует каждый из них и обеспечивает их взаимозаменяемость. Он позволяет модифицировать алгоритмы независимо от их использования на стороне клиента.

А его диаграмма выглядит так:

strategy_pattern0001Паттерн «Стратегия» является поведенческим паттерном и позволяет управлять поведением объектов, обладая при этом следующими достоинствами:

  • Инкапсуляция реализации различных алгоритмов;
  • Система становится независимой от возможных изменений правил и алгоритмов;
  • Вызов всех алгоритмов одним стандартным способом;
  • Отказ от использования переключателей и/или условных операторов

И следующими недостатками:

  • Создание большого количества дополнительных классов;

«Стратегия» является довольно интересным паттерном проектирования, который дает возможность создания взаимозаменяемых алгоритмов с унифицированными способом доступа к ним (достигается с помощью инкапсуляции поведения), а также дает возможность сделать объект более динамичным, добавляя стандартные способы изменения поведения с помощью простых и понятных методов.

Напоследок, полный код примера

import dfl.all;
import dlib.image;

// создаем свой тип для представления цвета (так как структуры описывающие цвета на поверхностях разные)
struct RGBColor
{
    ubyte R, G, B;
    
    this(ubyte R, ubyte G, ubyte B)
    {
        this.R = R;
        this.G = G;
        this.B = B;
    }
    
    // перегружаем преобразование нашей структуры в аналогичную из DFL
    Color opCast(T : Color)()
    {
        return Color(R,G,B);
    }
    
    // перегружаем преобразование нашей структуры в аналогичную из DLib
    Color4f opCast(T : Color4f)()
    {
        float r = R / 255.0f;
        float g = G / 255.0f;
        float b = B / 255.0f;
        
        return Color4f(r, g, b);
    }
}


// интерфейс для точки
interface surfacePoint
{
    // void setColor(ubyte R, ubyte G, ubyte B); // как вариант
    void setColor(RGBColor color);
    void draw(int X, int Y);
}


// интерфейс для обычной ломанной (прямая - частный случай для ломанной)
interface surfaceLine
{
    void setColor(RGBColor color);
    void draw(int[] points...);
}


// точка в DFL
class dflPoint : surfacePoint
{
private: 
    Graphics graphics;
    Color color;
    
public:
    this(Graphics graphics)
    {
        this.graphics = graphics;
    }
    
    void setColor(RGBColor color)
    {
        this.color = cast(Color) color;
    }
    
    void draw(int X, int Y)
    {
        Pen pen = new Pen(color, 1);
        graphics.drawLine(pen, X, Y, X + 1, Y + 1);
    }
}


// точка в dlib
class dlibPoint : surfacePoint
{
private:
    SuperImage superImage;
    Color4f color;
    
public:
    this(SuperImage superImage)
    {
        this.superImage = superImage;
    }
    
    void setColor(RGBColor color)
    {
        this.color = cast(Color4f) color;
    }
    
    void draw(int X, int Y)
    {
        superImage[X, Y] = color;
    }
}



// линия с точки зрения DFL
class dflLine : surfaceLine
{
private:
    Graphics graphics;
    Color color;
    
public:
    this(Graphics graphics, Color color = Color(0,0,0))
    {
        this.graphics = graphics;
    }
    
    void setColor(RGBColor color)
    {
        this.color = cast(Color) color;
    }
    
    void draw(int[] points...)
    {
        Point[] XY;
        Pen pen = new Pen(color, 1);
        
        for (int i = 0; i < points.length; i += 2)
        {
            XY ~= Point(points[i], points[i+1]);
        }
        
        graphics.drawLines(pen, XY);
    }
}


// линия с точки зрения dlib
class dlibLine : surfaceLine
{
private:
    SuperImage superImage;
    Color4f color;
    
    // рисование линии методом DDA, т.к стандартный примитив отсутствует
    void drawDDALine(ref SuperImage simg, Color4f color, int x1, int y1, int x2, int y2)
    {
        import std.algorithm : max;
        import std.math : abs;
        
        int deltaX = abs(x1 - x2);
        int deltaY = abs(y1 - y2);
        int length = max(deltaX, deltaY);
        
        if (length == 0)
        {
            simg[x1, y1] = color;
        }
        
        float dx = cast(float) (x2 - x1) / cast(float) length;
        float dy = cast(float) (y2 - y1) / cast(float) length;
        float x = x1;
        float y = y1;
        
        length++;
        while(length--)
        {
            x += dx;
            y += dy;
            simg[cast(int) x, cast(int) y] = color;
        }
        
    }
    
public:
    this(SuperImage superImage)
    {
        this.superImage = superImage;
    }
    
    void setColor(RGBColor color)
    {
        this.color = cast(Color4f) color;
    }
    
    void draw(int[] points...)
    {
        auto firstX = points[0];
        auto firstY = points[1];
        
        for (int i = 2; i < points.length; i += 2)
        {
            drawDDALine(superImage, color, firstX, firstY, points[i], points[i+1]);
            firstX = points[i];
            firstY = points[i+1];
        }
    }
}


// вот тот тип который нам действительно нужен - поверхность
abstract class Surface
{
protected:
    surfaceLine line;
    surfacePoint point;
    RGBColor color;
    
public:
    void setColor(RGBColor color)
    {
        this.color = color;
    }
    
    // делегируем отрисовку конкретному экземпляру, реализующему интерфейс
    void drawPoint(int X, int Y)
    {
        point.setColor(color);
        point.draw(X, Y);
    }
    
    // делегируем отрисовку конкретному экземпляру, реализующему интерфейс
    void drawLine(int[] points...)
    {
        line.setColor(color);
        line.draw(points);
    }
}


// поверхность образуемая DFL2
class dflSurface : Surface
{
    this(Graphics graphics)
    {
        point = new dflPoint(graphics);
        line =  new dflLine(graphics);
    }
}


// поверхность образуемая dlib
class dlibSurface : Surface
{
    this(SuperImage superImage)
    {
        point = new dlibPoint(superImage);
        line =  new dlibLine(superImage);
    }
}


// экспериментальная форма DFL2
class MyForm: dfl.form.Form
{
    this()
    {
        initializeMyForm();
    }
    
    // перегружаем отрисовку окна
    override protected void onPaint(PaintEventArgs ea)
    {
        super.onPaint(ea); // вызов отрисовки самого окна
        
        SuperImage simg = image(500, 500); // картинка для dlib
        
        // статический тип - surfaceLine, динамический тип - dlibLine
        surfaceLine line = new dlibLine(simg);
        // но все равно вызовется нужный метод draw
        line.draw(10, 10, 100, 100);
        // теперь динамический тип - dflLine
        line = new dflLine(ea.graphics); // ea.graphics - это объект типа Graphics
        // та же самая линия, но теперь в окне, а не в файле изображения
        line.draw(10, 10, 100, 100);
        
        Surface surface = new dlibSurface(simg); // первая поверхность
        surface.setColor(RGBColor(0,255,0));     // цвет тона (т.е примитивов)
        
        import std.math : sin;
        
        Surface surface2 = new dflSurface(ea.graphics); // вторая поверхность
        surface2.setColor(RGBColor(0,255,0));            
        
        for (float i = -250.0; i < 250; i += 0.1)
        {
            auto X = cast(int) (500 + i);
            auto Y = cast(int) (250 + 250 * sin(i / 20.0));
            
            surface.drawPoint(X, Y); // точка на первой поверхности
            surface2.drawPoint(X, Y); // точка на второй поверхности
        }
        
        simg.savePNG("sample.png"); // сохраняем картинку
        ea.graphics.drawLine(new Pen(Color(245,0,100), 2), 12, 50, 345, 70); // рисуем что-нибудь в окне 
    }
    
    private void initializeMyForm()
    {
        text = "My Form";
        clientSize = dfl.all.Size(496, 474);
        // paint ~= &this.onPaint;
    }
}

void main()
{
    try
    {
        Application.enableVisualStyles();
        Application.run(new MyForm);
    }
    catch(DflThrowable o)
    {
        msgBox(o.toString(), "Фатальный сбой", MsgBoxButtons.OK, MsgBoxIcon.ERROR);
    }
    
}

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

P.S.: Надеюсь, вы прекрасно понимаете, что этот код работает пока только под Windows – для устранения этого фатального недостатка, разместите все, что связано с DFL2 в блок version(Windows) {}; …

Добавить комментарий