SRP – Single Responsibility Principle w praktyce

O Single Responsibility Principle, pierwszej zasadzie S.O.L.I.D.-nego oprogramowania napisano sporo, jednak prawie wszystkie artykuły, które czytałem odnosiły się do tematu od bardzo teoretycznej strony. Poniżej przedstawiam jak najbardziej „produkcyjne” zastosowanie SRP, które powstało przy pisaniu części aplikacji odpowiedzialnej za ładowanie prostych modeli z formatu OBJ.

Intro

Czym właściwie jest SRP? Definicje (dwie, które chyba najlepiej opisują to pojęcie) mówią, że:

  1. Every object should have a single responsibility, and that responsibility should be entirely encapsulated by the class. (Każdy obiekt powinien być odpowiedzialny tylko za jedną funkcjonalność i ta funkcjonalność powinna być w całości „zamknięta” przez klasę)
  2. There is one and only one reason to change a class. (Jest jedna i tylko jedna przyczyna zmiany w klasie)

Należy zacząć myśleć o problemie do rozwiązania nie jak o całości, ale o zbiorze dużo łatwiejszych do rozwiązania podproblemów.  Porównaniem nieprogramistycznym może być administracja USA. Cały kraj jest kierowany przez prezydenta, Senat i Kongres, ale wiadomo, że tylko te trzy organy nie byłyby w stanie efektywnie ogarnąć problemów od skali lokalnej do ogólnokrajowej. Dlatego terytorium USA podzielone jest na stany, a te z kolei na hrabstwa, każde odrębnie zarządzane, zajmujące się problemami innej skali. Kluczem jest efektywna wymiana informacji pomiędzy kolejnymi szczeblami administracyjnymi.

Jako, że nie lubię się mieszać w politykę, wróćmy do tematyki związanej w programowaniem. Po spojrzeniu na problem jako całość, programista (projektant) powinien zacząć wyodrębniać jego mniejsze składowe i, tym samym, w naturalny sposób dojść do struktury klas. Zasada „dziel i rządź”, mająca tak oczywiste zastosowanie w rekurencji, dostaje tu świeży wiatr w żagle.

  • Podziel problem na mniejsze podproblemy
  • Podziel podproblemy na pod-podproblemy
  • Dziel dalej, aż dojdziesz do mikroproblemu, którego rozwiązanie możesz zawrzeć w pojedynczej klasie

Skoro klasa mikroproblemu ma tylko jedno zadanie, jest tylko jedna przyczyna aby ją zmienić – należy to zrobić wtedy, gdy zmienia się mikroproblem (wyjaśnienie drugiej zasady z początku postu).

Myślenie w kategorii mikro ma też szereg innych zalet, wymieniając tylko najważniejsze z nich: lepsza czytelność kodu, łatwiejsze jego utrzymanie, rozszerzanie i testowanie. Poza tym wymiana jednej z części kodu nie musi powodować zmian w innych (najczęściej już przetestowanych) elementach, przez zastosowanie dobrego systemu interfejsów.

Problem

Zadanie jest proste, dodatkowo ograniczone (nawet nie ze względu na notkę 😉 ). Należy wczytać z pliku OBJ model w postaci zbioru trójkątów. Nie będę opisywał całego formatu pliku (jest do znalezienia m.in. tu i tu), interesować mnie będą jedynie następujące informacje:

v x y z

gdzie v to znak oznaczający linię zawierającą pozycję wierzchołka, a (x, y, z) są jego współrzędnymi, oraz:

f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3

tu f to znak oznaczający linię zawierającą informacje o trójkącie, po nim znajdują się trzy zestawy indeksów (v1, v2, v3) to indeksy wierzchołków z listy zawartej w tym samym pliku, (vt1, vt2, vt3) – indeksy koordynatów tekstury odpowiednio dla tych wierzchołków oraz (vn1, vn2, vn3) – indeksy z listy wektorów normalnych. Indeksy dotyczące tekstur i wektorów normalnych są opcjonalne, ale i tak nie są potrzebne w tym przypadku.

Dygresja

Swiss Army Knife

Swiss Army Knife

Kiedyś (na potrzeby innej aplikacji) stworzyłem klasę reprezentującą powierzchnie parametryczne . Były w niej przechowywane listy wierzchołków, parametry do obliczania krzywych itd. Klasa zawierała również metody do wczytywania siatki z pliku, zapis do XML, oraz metodę rysującą powierzchnię. Teraz, gdy patrzę na jej strukturę, przypomina ona trochę tzw. Swiss Army Knife. Przez cały rozwój tamtej aplikacji liczba zmian w tej klasie była potężna – wszystko, od wczytywania, przez obliczenia aż do wyświetlana wymagało ingerencji w tą, a przez to pośrednio i w inne klasy.

Można powiedzieć, że aspirowała do nagrody „Najfajniejszego noża szwajcarskiego na świecie”. Przegrała jedynie z tym cudeńkiem:

Swiss Army Knife

Kurtyna w górę

Pierwsza wersja kodu była zorientowana na działanie, nie piękno kodu.

std::vector<Geometry::Triangle*> triangles;
std::vector<Geometry::Vector3*> vertices;

int main()
{
    std::string filename("data.obj");
    std::string line;
    std::ifstream reader(filename.c_str());
    if (!reader.is_open())
        throw std::exception(string("Could not open file ").append(filename).c_str());

    std::string* splits;
    std::string* splits2;

    try
    {
        while (!reader.eof())
        {
            getline(reader, line);

            if (line.empty())
                continue;
            int partsCount;
            Split(&splits, line, partsCount, ' ');

            try
            {
                if (partsCount <= 0)
                    throw std::exception("Error reading line params.");

                if (splits[0] == "v")
                {
                    float x = StringToFloat(splits[1]);
                    float y = StringToFloat(splits[2]);
                    float z = StringToFloat(splits[3]);

                    vertices.push_back(new Geometry::Vector3(x, y, z));
                }
            }
            catch (std::exception&)
            {
                delete[] splits;
                delete[] splits2;
                throw std::exception("Error reading file.");
            }
        }
    }
    catch (std::exception&)
    {
        throw std::exception("Error reading file.");
    }

    reader.seekg(0);

    try
    {
        while (!reader.eof())
        {
            getline(reader, line);

            if (line.empty())
                continue;

            int partsCount;
            Split(&splits, line, partsCount, ' ');

            try
            {
                if (partsCount <= 0)
                    throw std::exception("Error reading line params.");

                if (splits[0] == "f")
                {
                    Split(&splits2, splits[1], partsCount, '/');
                    int i1 = StringToInt(splits2[0]);
                    Split(&splits2, splits[2], partsCount, '/');
                    int i2 = StringToInt(splits2[0]);
                    Split(&splits2, splits[3], partsCount, '/');
                    int i3 = StringToInt(splits2[0]);

                    triangles.push_back(new Geometry::Triangle(*vertices[i1], *vertices[i2], *vertices[i3]));
                }
            }
            catch (std::exception&)
            {
                delete[] splits;
                delete[] splits2;
                throw std::exception("Error reading file.");
            }
        }
        delete[] splits;
        delete[] splits2;
    }
    catch (std::exception&)
    {
        throw std::exception("Error reading lights file.");
    }

    for (vector<Vector3*>::iterator it = vertices.begin(); it != vertices.end(); ++it)
        std::cout << (**it) << std::endl;

    for (vector<Triangle*>::iterator it = triangles.begin(); it != triangles.end(); ++it)
        std::cout << (**it) << std::endl;

    system("PAUSE");
    return 0;
}

Zawartość prostego pliku, którego użyłem do testów (puste linie zostawione celowo):

f 1//1 2//1 3//1

v 1.0 4.0 -5.0
v 2.0 4.0 -5.0
v 3.0 4.0 -5.0
v 4.0 4.0 -5.0

v 8.0 4.0 -5.0
v 9.0 4.0 -5.0

vt 3 3
v -11 -5.5 -4

f 0//3 1//3 2//3
f 3 4 5

Powyższy fragment kodu jest odpowiedzialny za wszystko: załadowanie pliku, wczytanie go (linia po linii), parsowanie współrzędnych wektorów i trójkątów, następnie wyświetlanie w oknie konsoli (szumnie nazwijmy to „renderingiem” 😉 ). Przeprowadzimy na tym kodzie dość brutalny refactoring, dzięki czemu będzie można pokazać go ludziom.

Refactoring

Krok 1

Wyróżnijmy interfejs obiektu, który ma być efektem działania aplikacji.

class IMesh
{
    public:
        virtual void SetVertices(std::vector<Vector3*> data) = 0;
        virtual void SetTriangles(std::vector<Triangle*> data) = 0;

        std::vector<Geometry::Vector3*> GetVertices() const { return vertices; }
        std::vector<Geometry::Triangle*> GetTriangles() const { return triangles; }

    protected:
        std::vector<Geometry::Vector3*> vertices;
        std::vector<Geometry::Triangle*> triangles;
};

Krok 2

Po konkretnym zdefiniowaniu CZEGO oczekujemy przydałoby się zdecydować JAK chcemy to osiągnąć. Wyodrębnijmy więc interfejs ładujący siatkę z dysku:

class IMeshLoader
{
    public:
        virtual IMesh* LoadMesh(const char* filename) const = 0;

    protected:
        virtual std::vector<Geometry::Triangle*> GetTriangles(const char* filename) const = 0;
        virtual std::vector<Geometry::Vector3*> GetVertices(const char* filename) const = 0;
};

a następnie zastosujmy powstały kod do wersji pierwotnej:

IMesh* mesh;
IMeshLoader* meshLoader;

int main()
{
    std::string filename("data.obj");

    IMeshLoader = /* tu następuje inicjalizacja obiektu meshLoader */;

    mesh = meshLoader.LoadMesh(filename.c_str());

    for (std::vector<Geometry::Vector3*>::iterator it = mesh->GetVertices().begin(); it != mesh->GetVertices().end(); ++it)
        std::cout << (**it) << std::endl;

    for (std::vector<Geometry::Triangle*>::iterator it = mesh->GetTriangles().begin(); it != mesh->GetTriangles().end(); ++it)
        std::cout << (**it) << std::endl;

    system("PAUSE");
    return 0;
}

Ładniej, krócej i, przede wszystkim, logiczniej.

Krok 3:

Uzupełnijmy teraz treść klas implementujących wyodrębnione przed chwilą interfejsy (lub, poprawniej: stwórzmy odpowiednie klasy potomne, bo w C++ nie ma explicite pojęcia „interfejsu”)

OBJMesh.h

class OBJMesh : public IMesh
{
    public:
        virtual void SetVertices(std::vector<Geometry::Vector3*> data) { vertices = data; }
        virtual void SetTriangles(std::vector<Geometry::Triangle*> data) { triangles = data; }
};

ObjMeshLoader.h

class OBJMeshLoader : public IMeshLoader
{
    public:
        virtual IMesh* LoadMesh(const char* filename) const;

    protected:
        virtual std::vector<Geometry::Triangle*> GetTriangles(const char* filename) const;
        virtual std::vector<Geometry::Vector3*> GetVertices(const char* filename) const;
};

ObjMeshLoader.cpp

std::vector<Geometry::Vector3*> OBJFileLoader::GetVertices(const char *filename) const
{
    std::vector<Vector3*> result;
    std::ifstream reader(filename);
    if (!reader.is_open())
        throw std::exception(string("Could not open file ").append(filename).c_str());

    try
    {
        std::string line;
        std::string* splits;
        while (!reader.eof())
        {
            getline(reader, line);

            if (line.empty())
                continue;

            int partsCount;
            Split(&splits, line, partsCount, ' ');

            try
            {
                if (partsCount <= 0)
                    throw std::exception("Error reading line params.");

                if (splits[0] == "v")
                {
                    float x = StringToFloat(splits[1]);
                    float y = StringToFloat(splits[2]);
                    float z = StringToFloat(splits[3]);

                    result.push_back(new Geometry::Vector3(x, y, z));
                }
            }
            catch (std::exception&)
            {
                delete [] splits;
                throw std::exception("Error reading file.");
            }
        }

        delete[] splits;
        return result;
    }
    catch (std::exception&)
    {
        throw std::exception("Error reading file.");
    }
}

std::vector<Geometry::Triangle*> OBJFileLoader::GetTriangles(const char *filename) const
{
    std::vector<Geometry::Vector3*> vertices = GetVertices(filename);
    std::ifstream reader(filename);
    if (!reader.is_open())
        throw std::exception(string("Could not open file ").append(filename).c_str());

    std::vector<Geometry::Triangle*> result;

    try
    {
        std::string line;
        std::string* splits;
        std::string* splits2;

        while (!reader.eof())
        {
            getline(reader, line);

            if (line.empty())
                continue;
            int partsCount;
            Split(&splits, line, partsCount, ' ');
            try
            {
                if (partsCount <= 0)
                    throw std::exception("Error reading line params.");

                if (splits[0] == "f")
                {
                    int pC;
                    Split(&splits2, splits[1], pC, '/');
                    int i1 = StringToInt(splits2[0]);
                    Split(&splits2, splits[2], pC, '/');
                    int i2 = StringToInt(splits2[0]);
                    Split(&splits2, splits[3], pC, '/');
                    int i3 = StringToInt(splits2[0]);

                    result.push_back(new Geometry::Triangle(*vertices[i1], *vertices[i2], *vertices[i3]));
                }
            }
            catch (std::exception&)
            {
                delete [] splits;
                delete [] splits2;
                throw std::exception("Error reading file.");
            }
        }
        delete[] splits;
        delete[] splits2;
        return result;
    }
    catch (std::exception&)
    {
        throw std::exception("Error parsing triangles.");
    }
}

Nie ma tu żadnych sztuczek – po prostu kod z pierwszej wersji został rozbity na kilka metod i umieszczony w odpowiedniej klasie.

Mając wyodrębnione interfejsy, nic nie stoi na przeszkodzie, aby napisać kilka klas potomnych, np. Loadery z innych formatów plików, a wprowadzenie ich do programu odbędzie się jedynie na poziomie podmiany konstruktora.

Krok 4:

Wszystko jest na jak najlepszej drodze do pomyślnego zakończenia pracy. Należy jednak zwrócić uwagę na pewną rzecz. Otóż metody GetVertices oraz GetTriangles mają więcej zadań, niż wynika to z zasady „dziel i rządź” – obie są odpowiedzialne za wczytanie pliku ORAZ parsowanie współrzędnych tak wektora, jak i indeksów trójkąta. Naprawimy to.

Aby zmaksymalizować uniwersalność projektu, wyodrębnimy dwa interfejsy parserów: IVector3Parser i ITriangleParser

static const std::string VectorPrefix("v ");

class IVector3Parser
{
public:
virtual Vector3* Parse(std::string line) const = 0;
};

static const std::string FacePrefix("f ");

class ITriangleParser
{
public:
virtual void SetVertices(std::vector<Vector3*> data) = 0;
virtual Triangle* Parse(std::string line) const = 0;

protected:
std::vector<Vector3*> vertices;
};

Parser indeksów musi być zasilony listą (wektorem) wierzchołków, żeby miał skąd czerpać dane przy konstruowaniu trójkątów. Wyodrębnijmy kod z odpowiednich metod do klas implementujących powyższe interfejsy.

Vector3Parser.h

class Vector3Parser : public IVector3Parser
{
    public:
    virtual Vector3* Parse(std::string line) const;
};

Vector3Parser.cpp

Vector3* Vector3Parser::Parse(std::string line) const
{
    std::string* splits;
    int partsCount;
    Split(&splits, line, partsCount, ' ');

    if (partsCount != 4)
    throw exception("Error parsing Vector3.");

    float x = StringToFloat(splits[1]);
    float y = StringToFloat(splits[2]);
    float z = StringToFloat(splits[3]);

    return new Vector3(x, y, z);
}

TriangleParser.h

class TriangleParser : public ITriangleParser
{
    public:
        virtual Triangle* Parse(std::string line) const;
        virtual void SetVertices(std::vector<Vector3*> data);
};

TriangleParser.cpp

void TriangleParser::SetVertices(std::vector<Vector3*> data)
{
    this->vertices = data;
}

Triangle* TriangleParser::Parse(std::string line) const
{
    std::string* splits;
    int partsCount;
    Split(&splits, line, partsCount, ' ');

    if (partsCount != 4)
        throw exception("Error parsing triangle.");

    std::string* splits2;

    Split(&splits2, splits[1], partsCount, '/');
    int i1 = StringToInt(splits2[0]);
    Split(&splits2, splits[2], partsCount, '/');
    int i2 = StringToInt(splits2[0]);
    Split(&splits2, splits[3], partsCount, '/');
    int i3 = StringToInt(splits2[0]);

    delete [] splits;
    delete [] splits2;

    return new Triangle(*vertices[i1], *vertices[i2], *vertices[i3]);
}

Aby wprowadzić do kodu nowe klasy, modyfikujemy interfejs IMeshLoader, umieszczając w nim nowe elementy:

class IMeshLoader
{
    public:
        virtual IMesh* LoadMesh(const char* filename) const = 0;

    protected:
        ITriangleParser* triangleParser;
	IVector3Parser* vector3Parser;

        virtual std::vector<Geometry::Triangle*> GetTriangles(const char* filename) const = 0;
        virtual std::vector<Geometry::Vector3*> GetVertices(const char* filename) const = 0;
};

i uaktualniamy klasę potomną (doszedł konstruktor i zmiany w metodach w miejscu parsowania):

OBJMeshLoader::OBJMeshLoader()
{
	vector3Parser = new Vector3Parser();
	triangleParser = new TriangleParser();
}

std::vector<Geometry::Vector3*> OBJFileLoader::GetVertices(const char *filename) const
{
    std::vector<Vector3*> result;
    std::ifstream reader(filename);
    if (!reader.is_open())
        throw std::exception(string("Could not open file ").append(filename).c_str());

    try
    {
        string line;
        while (!reader.eof())
        {
            getline(reader, line);

            if (line.empty())
                continue;

            if (line.compare(0, VectorPrefix.size(), VectorPrefix) == 0)
                result.push_back(vector3Parser->Parse(line));
        }
        return result;
    }
    catch (exception&)
    {
        throw exception("Error parsing file.");
    }
}

std::vector<Geometry::Triangle*> OBJFileLoader::GetTriangles(const char *filename) const
{
    std::vector<Vector3*> vertices = GetVertices(filename);
    triangleParser->SetVertices(vertices);

    std::ifstream reader(filename);
    if (!reader.is_open())
        throw exception(string("Could not open file ").append(filename).c_str());

    std::vector<Triangle*> result;

    try
    {
        string line;
        while (!reader.eof())
        {
            getline(reader, line);

            if (line.empty())
                continue;

            if (line.compare(0, FacePrefix.size(), FacePrefix) == 0)
                result.push_back(triangleParser->Parse(line));
        }
    }
    catch (exception&)
    {
        throw exception("Error parsing file.");
    }
    return result;
}

To już prawie koniec pracy. Mamy krótkie klasy, odpowiedzialne za pojedyncze czynności, w dodatku opakowane interfejsami, które pozwalają na wymianę funkcjonalności „w locie”.
Pozostała już ostatnia rzecz, czyli wyświetlenie wyników pracy na ekranie. Jak się do niego zabierzemy? Odpowiedź może być tylko jedna: interfejs + klasa pochodna.


class IMeshRenderer
{
 public:
 virtual void RenderVertices(IMesh* mesh) const = 0;
 virtual void RenderTriangles(IMesh* mesh) const = 0;
};

ObjMeshConsoleRenderer.h


class OBJMeshConsoleRenderer : public IMeshRenderer
{
 public:
 virtual void RenderVertices(IMesh* mesh) const;
 virtual void RenderTriangles(IMesh* mesh) const;
};

ObjMeshConsoleRenderer.cpp


void OBJMeshConsoleRenderer::RenderVertices(IMesh *mesh) const
{
 std::vector<Vector3*> m = mesh->GetVertices();

 for (vector<Vector3*>::iterator it = m.begin(); it != m.end(); ++it)
 std::cout << (**it) << std::endl;
}

void OBJMeshConsoleRenderer::RenderTriangles(IMesh *mesh) const
{
 std::vector<Triangle*> t = mesh->GetTriangles();

 for (vector<Triangle*>::iterator it = t.begin(); it != t.end(); ++it)
 std::cout << (**it) << std::endl;
}

Diagram klas przedstawiający kod po refaktoryzacji:

Class Diagram

Podsumowanie

Być może ilość klas i wyodrębnionych elementów jest w tym przykładzie zbyt duża. Należy jednak pamiętać o tym, że to tylko niewielki fragment kodu, a aplikacje produkcyjne są kilkanaście/-dziesiąt razy większe. Dlatego tak ważna jest zasada SRP – niezależnie od wielkości problemu, rozbija się go na mniejsze, które są łatwiejsze do rozwiązania/utrzymania w pojedynkę.

Kod, który otrzymaliśmy jest przyjemniejszy do czytania, łatwiejszy do utrzymania i testowania. I obyśmy tylko taki produkowali.

Be Sociable, Share!
czoper opublikowano dnia 2010-7-15 Kategoria: Programowanie | Tagi:, , ,

Odpowiedzi: 2 Zostaw komentarz

  1. #2Jarek Żeliński @ 2012-8-24 20:27

    zamiana złożoności zawartości jednej klasy na złożoność klas daje po pierwsze ten efekt, że złożoność klas jest zawsze co najmniej o rząd mniejsza, po drugie przyszła ewentualna modyfikacja dotyczy z reguły kilkunastu linii jednej z wielu klas (pozostałe są nieruszone – hermetyzacja) a nie kilkuset linii jednej megaklasy (a taka łatwiej „zepsuć”).

  2. #1Xion @ 2010-7-15 15:56

    Zamieniłeś złożoność samych klas na złożoność zależności między nimi. Przypomina mi to rzekome hasło OOP-u: jeśli coś jest zbyt skomplikowane, dodaj więcej klas 😉 Chyba nie tędy droga…

Zostaw odpowiedź

(Ctrl + Enter)