SOLID czyli o dobrych praktykach w programowaniu obiektowym

SOLID o co chodzi i czym jest

S.O.L.I.D jest mnemonikiem wymyślony przez Robera C. Martina. Jest to skrót od 5 reguł, które mają na celu pisanie dobrego kodu. A więc co oznaczają poszczególne litery.

  • S – Single Responsibility Principle (Zasada jednej odpowiedzialności)
  • O – Open/Closed Principle (Zasada otwarte – zamknięte)
  • L – Liskov substitution principle (Zasada podstawienia Liskov)
  • I – Interface segregation principle (Zasada segregacji interfejsów)
  • D – Dependency inversion principle (Zasada odwrócenia zależności)

 

Single responsibility principle (Zasada jednej odpowiedzialności)

Jest bardzo prostą regułą, która mówi pojedynczej odpowiedzialności danej klasy. Sprowadza się do tego, że może istnieć tylko jeden powód do modyfikacji takiej klasy. Dzięki stosowaniu tej reguły zapewniamy sobie większą czytelność kodu, oraz minimalizujemy ryzyko wystąpienia problemów przy modyfikacji takiej klasy. Także taka klasa powinna realizować jedną konkretną funkcjonalność.

Przykład

Stwórzmy kawałek kodu, realizujący prostą funkcjonalność dziennika do którego dodajemy kolejne pozycje. Program umożliwia też zapisanie dziennika między innymi do pliku

    public class Journal
    {

        private readonly List<string> entries = new List<string>();
        private static int count = 0;

        public int AddEntry(string text)
        {
            entries.Add($"{++count}: {text}");
            return count;
        }

        public void RemoveEntry(int index)
        {
            entries.RemoveAt(index);
        }

        public override string ToString()
        {
            return string.Join(Environment.NewLine, entries);
        }

        public void Save(string filename)
        {
            File.WriteAllText(filename, ToString());
        }

        public static Journal Load(string filename)
        {
            return new Journal;
        }

        public void Load(Uri uri)
        {

        }
    }
    public class Demo
    {
        static void Main(string[] args)
        {
            var j = new Journal();
            j.AddEntry("I cried troday");
            j.AddEntry("I fix bug");
            Console.WriteLine(j);
        }
    }

Jak widzimy w naszej klasie Journal wszystko byłoby jeszcze ok, do momentu jak zaczęliśmy dodawać operacje na plikach. W takim wypadku klasa ta ma zdecydowanie więcej odpowiedzialności niż jedną. Spróbujmy to w takim razie zrealizować poprawnie.

 public class Journal
    {

        private readonly List<string> entries = new List<string>();
        private static int count = 0;

        public int AddEntry(string text)
        {
            entries.Add($"{++count}: {text}");
            return count;
        }

        public void RemoveEntry(int index)
        {
            entries.RemoveAt(index);
        }

        public override string ToString()
        {
            return string.Join(Environment.NewLine, entries);
        }

        // Do tego momentu jest jeszcze ok, bo wszystko to dotyczy Journal

    }

    public class Persistence
    {
        public void SaveToFile(Journal journal, string filename, bool overwrite = false)
        {
            if (overwrite || !File.Exists(filename))
                File.WriteAllText(filename, journal.ToString());
        }
    }
    public class Demo
    {
        static void Main(string[] args)
        {
            var j = new Journal();
            j.AddEntry("I cried troday");
            j.AddEntry("I fix bug");
            Console.WriteLine(j);

            var p = new Persistence();
            var filename = $"/Users/mati/tmp/journal.txt";
            p.SaveToFile(j, filename, true);
        }
    }

W tym przypadku wydzieliliśmy operację odpowiedzialne za operacji zapisu i przenieśliśmy do nowej klasy. Oczywiście w podobny sposób potraktowalibyśmy metody odczytujące itp. jednak uważam że nie ma sensu tego tutaj pisać. Teraz każda klasa ma po jednej funkcjonalności. Journal manipuluje kolekcją zawierającą wpisy, Persistence odpowiada za zapis do pliku.

 

Open/Closed Principle (Zasada otwarte – zamknięte)

Zasada otwarte – zamknięte mówi o tym, aby moduły naszego programu były otwarte na rozszerzanie/rozbudowę, ale jednocześnie zamknięte na modyfikację. W tym wypadku otwarte oznacza, że nasze klasy, funkcje powinny dać się łatwo rozbudować korzystając np. z dziedziczenia. Zamknięte oznacza, że przy rozwoju oprogramowania napisany kod nie powinien być modyfikowany.

Przykład

Wyobraźmy sobie jakiś produkt, który posiada właściwości takie jak nazwa, kolor i rozmiar. Z tych wszystkich produktów chcemy wyfiltrować

    public enum Color
    {
        Red, Blue, Green
    }

    public enum Size
    {
        Small, Medium, Large, Yuge
    }

    public class Product
    {
        public string Name;
        public Color Color;
        public Size Size;

        public Product(string name, Color color, Size size)
        {
            if (name == null)
                throw new ArgumentNullException(paramName: nameof(name));

            Name = name;
            Color = color;
            Size = size;
        }
    }

    public class ProductFilter
    {
        public IEnumerable<Product> FilterBySize(IEnumerable<Product> products, Size size)
        {
            foreach (var p in products)
                if (p.Size == size)
                    yield return p;
        }

        public IEnumerable<Product> FilterByColor(IEnumerable<Product> products, Color color)
        {
            foreach (var p in products)
                if (p.Color == color)
                    yield return p;
        }

        public IEnumerable<Product> FilterBySizeAndColor(
            IEnumerable<Product> products, Color color, Size size)
        {
            foreach (var p in products)
                if (p.Color == color && p.Size == size)
                    yield return p;
        }
    }
    public class Demo
    {
        static void Main(string[] args)
        {
            var apple = new Product("Apple", Color.Green, Size.Small);
            var tree = new Product("Tree", Color.Green, Size.Large);
            var house = new Product("House", Color.Blue, Size.Large);

            Product[] products = { apple, tree, house };

            var pf = new ProductFilter();

            Console.WriteLine("Green products (old):");

            foreach (var p in pf.FilterByColor(products, Color.Green))
            {
                Console.WriteLine($" - {p.Name} is green");
            }
        }
    }

Jak widać pojawia się pewien problem. Przy każdym filtrowaniu musimy powielać prawie ten sam kod. Dodatkowo musimy modyfikować klasę ProductFilter co nie jest dobrym pomysłem, ani zgodnym z omawianą zasadą. Spróbujmy zatem zrobić to trochę inaczej.

    public enum Color
    {
        Red, Green, Blue
    }

    public enum Size
    {
        Small, Medium, Large, Yuge
    }

    public class Product
    {
        public string Name;
        public Color Color;
        public Size Size;

        public Product(string name, Color color, Size size)
        {
            Name = name ?? throw new ArgumentNullException(paramName: nameof(name));
            Color = color;
            Size = size;
        }
    }
  public interface ISpecification<T>
    {
        bool IsSatisfied(Product p);
    }

    public interface IFilter<T>
    {
        IEnumerable<T> Filter(IEnumerable<T> items, ISpecification<T> spec);
    }

    public class ColorSpecification : ISpecification<Product>
    {
        private Color color;

        public ColorSpecification(Color color)
        {
            this.color = color;
        }

        public bool IsSatisfied(Product p)
        {
            return p.Color == color;
        }
    }

    public class SizeSpecification : ISpecification<Product>
    {
        private Size size;

        public SizeSpecification(Size size)
        {
            this.size = size;
        }

        public bool IsSatisfied(Product p)
        {
            return p.Size == size;
        }
    }

    public class CombineSpecification<T> : ISpecification<T>
    {
        private ISpecification<T> first, second;

        public CombineSpecification(ISpecification<T> first, ISpecification<T> second)
        {
            this.first = first ?? throw new ArgumentNullException(paramName: nameof(first));
            this.second = second ?? throw new ArgumentNullException(paramName: nameof(second));
        }

        public bool IsSatisfied(Product p)
        {
            return first.IsSatisfied(p) && second.IsSatisfied(p);
        }
    }

    public class BetterFilter : IFilter<Product>
    {
        public IEnumerable<Product> Filter(IEnumerable<Product> items, ISpecification<Product> spec)
        {
            foreach (var i in items)
                if (spec.IsSatisfied(i))
                    yield return i;
        }
    }


 
public class Demo
    {
        static void Main(string[] args)
        {
            var apple = new Product("Apple", Color.Green, Size.Small);
            var tree = new Product("Tree", Color.Green, Size.Large);
            var house = new Product("House", Color.Blue, Size.Large);

            Product[] products = { apple, tree, house };

            var bf = new BetterFilter();
            WriteLine("Green products (new):");
            foreach (var p in bf.Filter(products, new ColorSpecification(Color.Green)))
                WriteLine($" - {p.Name} is green");

            WriteLine("Large products");
            foreach (var p in bf.Filter(products, new SizeSpecification(Size.Large)))
                WriteLine($" - {p.Name} is large");

            WriteLine("Large blue items");
            foreach (var p in bf.Filter(products,
              new CombineSpecification<Product>(new ColorSpecification(Color.Blue), 
                  new SizeSpecification(Size.Large)))
            )
            {
                WriteLine($" - {p.Name} is big and blue");
            }
        }
    }

Jak widać w tym przypadku możemy w bardzo prosty sposób dodawać nowe filtry nie modyfikując żadnej napisanej wcześniej klasy.

 

Liskov substitution principle (Zasada podstawienia Liskov)

Cytując wikipedię zasada ta brzmi tak: „Funkcje które używają wskaźników lub referencji do klas bazowych, muszą być w stanie używać również obiektów klas dziedziczących po klasach bazowych, bez dokładnej znajomości tych obiektów.” Oznacza, że powinniśmy móc używać np. klasy pochodnej dokładnie w ten sam sposób co klasę bazową. Mam nadzieję że poniższy przykład trochę zobrazuje to o czym piszę. Posłużę się standardowym przykładem opierającym się o kwadrat i prostokąt.

Przykład:

 

    public class Ractangle
    {
        public int Width { get; set; }
        public int Height { get; set; }

        public Ractangle()
        {
            
        }

        public Ractangle(int width, int height)
        {
            Width = width;
            Height = height;
        }

        public override string ToString()
        {
            return $"{nameof(Width)}: {Width}, {nameof(Height)}: {Height}";
        }
    }

    public class Squere : Ractangle
    {
        public new int Width
        {
            set { base.Height = base.Width = value; }
        }

        public new int Height
        {
            set { base.Height = base.Width = value; }
        }
    }

    public class Program
    {
        static public int Area(Ractangle r) => r.Height * r.Width;
        static void Main(string[] args)
        {
            Ractangle rc = new Ractangle(2, 3);
            Console.WriteLine($"{rc} has area : {Area(rc)}");

            Squere sc = new Squere();
            sc.Width = 4;
            Console.WriteLine($"{sc} has area : {Area(sc)}");

            Ractangle rsc = new Squere();
            rsc.Width = 4;
            Console.WriteLine($"{sc} has area : {Area(rsc)}");
        }
    }

Mamy tu przykład złamania zasady, ponieważ obiektu Squere  nie jesteśmy w stanie użyć dokładnie w ten sam sposób co Ractangle. W trzecim wyliczeniu pola dostaniem wartość zero, ponieważ wysokość dla rsc nadal wynosi 0.

A teraz poprawmy kod tak, aby omawiana zasada została spełniona.

    public class Ractangle
    {
        public virtual int Width { get; set; }
        public virtual int Height { get; set; }

        public Ractangle()
        {

        }

        public Ractangle(int width, int height)
        {
            Width = width;
            Height = height;
        }

        public override string ToString()
        {
            return $"{nameof(Width)}: {Width}, {nameof(Height)}: {Height}";
        }
    }

    public class Squere : Ractangle
    {
        public override int Width
        {
            set => base.Height = base.Width = value;
        }

        public override int Height
        {
            set => base.Height = base.Width = value;
        }
    }

    public class Program
    {
        static public int Area(Ractangle r) => r.Height * r.Width;
        static void Main(string[] args)
        {
            Ractangle rc = new Ractangle(2, 3);
            Console.WriteLine($"{rc} has area : {Area(rc)}");

            Ractangle sc = new Squere();
            sc.Width = 4;
            Console.WriteLine($"{sc} has area : {Area(sc)}");

            Ractangle rsc = new Squere();
            rsc.Width = 4;
            Console.WriteLine($"{rsc} has area : {Area(rsc)}");

            Console.ReadKey();
        }
    }

Teraz wyniki dostajemy takie, jakich oczekiwaliśmy. Jak widzicie wystarczyło jedynie zamiast tworzenia nowych właściwości klasy Squere nadpisać te z klasy bazowej.

 

Interface segregation principle (Zasada segregacji interfejsów)

W zasadzie tej chodzi o to, aby nie tworzyć wielkich rozbudowanych interfejsów. Zamiast tego rozdzielić je na mniejsze, które zawsze w razie potrzeby można ze sobą połączyć. Jest prosta zasada więc nie ma się co rozpisywać, więc może po prostu pokażę o co chodzi

Przykład:

    public class Document
    {
    }

    public interface IMachine
    {
        void Print(Document d);
        void Scan(Document d);
        void Fax(Document d);
    }

    public class MultiFunctionPrinter : IMachine
    {
        void IMachine.Fax(Document d)
        {
            // some implementation
        }

        void IMachine.Print(Document d)
        {
            // some implementation
        }

        void IMachine.Scan(Document d)
        {
            // some implementation
        }
    }

    public class OlfFashionedPrinter : IMachine
    {
        void IMachine.Fax(Document d)
        {
            throw new NotImplementedException();
        }

        void IMachine.Print(Document d)
        {
            // some implementation
        }

        void IMachine.Scan(Document d)
        {
            throw new NotImplementedException();
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
        }
    }

Mamy tutaj interfejs IMachine, który wymusza implementację 3 metod. Na pierwszy rzut oka nie wygląda to tak źle, ale spójrzmy co się dzieje, gdy taki interfejs próbuje zaimplementować dla starej drukarki (OldFashionedPrinter). Drukarka ta nie obsługuje faksowania i skanowania. Możemy to zrobić w lepszy sposób zgodnie z omawianą zasadą.

    public class Document
    {

    }

    public interface IMachine
    {
        void Print(Document d);
        void Scan(Document d);
        void Fax(Document d);
    }

    public interface IPrinter
    {
        void Print(Document d);
    }
    public interface IScanner
    {
        void Scan(Document d);
    }
    public interface IFax
    {
        void Fax(Document d);
    }

    public interface IMultiFuctionDevice : IScanner, IPrinter // ....
    {
    }

    public class Photocopier : IPrinter, IScanner
    {
        public void Print(Document d)
        {
            // some implementation
        }

        public void Scan(Document d)
        {
            // some implementation
        }
    }

    public class MultiFunctionMachine : IMultiFuctionDevice
    {
        private IPrinter printer;
        private IScanner scanner;

        public MultiFunctionMachine(IPrinter printer, IScanner scanner)
        {
            this.printer = printer;
            this.scanner = scanner;
        }

        public void Print(Document d)
        {
            printer.Print(d);
        }

        public void Scan(Document d)
        {
            scanner.Scan(d);
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
        }
    }

Dependency inversion principle (Zasada odwrócenia zależności)

Zasada ta mówi, że moduły wysokiego poziomu nie powinny być zależne od modułów niskiego poziomu. Powinny one korzystać z abstrakcji. A zatem lecimy z przykładem

Przykład:

 

    public enum Relationship
    {
        Parent,
        Child,
        Sibling
    }

    public class Person
    {
        public string Name;
    }

    // low-level module
    public class Relationships
    {
        private List<(Person, Relationship, Person)> relations
             = new List<(Person, Relationship, Person)>();

        public void AddParentAndChild(Person parent, Person child)
        {
            relations.Add((parent, Relationship.Parent, child));
            relations.Add((child, Relationship.Child, child));
        }

        public List<(Person, Relationship, Person)> Relations => relations;
    }

    // high level module
    public class Research   
    {
        public Research(Relationships relationships)
        {
            var relations = relationships.Relations;

            foreach (var r in relations.Where(
                x => x.Item1.Name == "John" &&
                     x.Item2 == Relationship.Parent
                ))
            {
                Console.WriteLine($"John has a child called {r.Item3.Name}");
                
            }

            Console.ReadKey();
        }

        static void Main(string[] args)
        {
            var parent = new Person {Name = "John"};
            var child = new Person { Name = "Chris" };
            var child2 = new Person { Name = "Mary" };

            var relationships = new Relationships();
            relationships.AddParentAndChild(parent, child);
            relationships.AddParentAndChild(parent, child2);

            new Research(relationships);
        }
    }

W powyższym przykładnie pojawia się problem zależności modułu wysokiego poziomu od modułu niskiego poziomu. Jeżeli zmieni się implementacja klasy Relationships prawdopodobnie konieczna również będzie zmiana klasy Research, co nie powinno mieć miejsca. Wprowadźmy zatem trochę abstrakcji.

    public enum Relationship
    {
        Parent,
        Child,
        Sibling
    }

    public class Person
    {
        public string Name;
    }

    public interface IRelationshipBrowser
    {
        IEnumerable<Person> FindAllChildrenOf(string name);
    }

    // low-level
    public class Relationships : IRelationshipBrowser
    {
        private List<(Person, Relationship, Person)> relations
             = new List<(Person, Relationship, Person)>();

        public void AddParentAndChild(Person parent, Person child)
        {
            relations.Add((parent, Relationship.Parent, child));
            relations.Add((child, Relationship.Child, child));
        }

        public IEnumerable<Person> FindAllChildrenOf(string name)
        {
            foreach (var r in relations.Where(
                    x => x.Item1.Name == name &&
                         x.Item2 == Relationship.Parent
                    ))
            {
                yield return r.Item3;
            }
        }
    }

    // high-level
    public class Research   
    {
        public Research(IRelationshipBrowser browser)
        {
            foreach (var p in browser.FindAllChildrenOf("John"))
            {
                Console.WriteLine($"John has a child called {p.Name}");
            }

            Console.ReadKey();
        }

        static void Main(string[] args)
        {
            var parent = new Person {Name = "John"};
            var child = new Person { Name = "Chris" };
            var child2 = new Person { Name = "Mary" };

            var relationships = new Relationships();
            relationships.AddParentAndChild(parent, child);
            relationships.AddParentAndChild(parent, child2);

            new Research(relationships);
        }
    }

W tym wypadku klasa Research nie ma żadnego powiązania z klasą Relationships dzięki wykorzystaniu abstrakcji. Klasa Relationships implementuje interfejs IRelationshipBrowser i jego metodę FindAllChildrenOf na swój sposób. Klasy Research nie interesuje sposób implementacji, ona jedynie z niej korzysta.

 

I to by było na tyle. Mam nadzieję, że udało mi się trochę przybliżyć zamysł jaki przyświecał twórcy tych reguł i to jak ważne one są.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *