SOLID

Zasady projektowe

  |   18 min czytania

Wstęp

Zasady SOLID opisują pięć podstawowych reguł programowania obiektowego, których celem jest tworzenie zrozumiałego, elastycznego i łatwiejszego do utrzymania kodu. Mają zastosowanie nie tylko w projekcie zorientowanym obiektowo, ale także mogą stanowić fundamenty dla metodologi pracy takich jak np. metodyki zwinne.

Single responsibility

Zasada pojedynczej odpowiedzialności (Single responsibility principle) mówi, że nigdy nie powinno być więcej niż jednego powodu do modyfikacji klasy. Innymi słowy każda klasa powinna być odpowiedzialna za realizacje pojedynczego tematu. Jest jednym z podstawowych elementów refaktoryzacji i skutkuje wydzieleniem mniejszych klas z jednej większej. Tworzenie kodu w oparciu o wiele małych klas zamiast kilku dużych pozwala przede wszysktim na uniknięcie powtórzeń tego samego fragmentu kodu. Modularyzacja znacząco przyczynia się do budowania zrozumiałej struktury projektu łatwiejszego w rozwoju i utrzymaniu.

data class Person(val name: String, val surname: String, val bornYear: Int,
                  val city: String, val street: String, val houseNumber: String,
                  val email: String, val phoneNumber: String)
{

    fun createSummary(): ByteArray {
        //create file with the data and return its bytes
        return byteArrayOf() //mock
    }

    fun isEmailValid(): Boolean {
        val pattern = Pattern.compile("^.+@.+\\..+$")
        val matcher = pattern.matcher(email)
        return matcher.matches()
    }

    fun isPhoneNumberValid(): Boolean {
        val pattern = Pattern.compile("^([+][0-9]{2})*[0-9]{9}\$")
        val matcher = pattern.matcher(phoneNumber)
        return matcher.matches()
    }
}

Klasa Person posiada zbiór właściwości, które mogłyby zostać podzielone na mniejsze obiekty, np. klasy Address i ContactDetails. Pociąga to za sobą również przeniesienie metod isEmailValid i isPhoneNumberValid do odpowiadającej im klasy ContactDetails ponieważ nie leżą one teraz w obowiązku typu Person. Ponadto metoda createPdfSummary realizująca generowanie pliku przypisuje dodatkową odpowiedzialność w związku z czym należy ją oddelegować do klasy SummaryGenerator. Dzięki takiemu podziałowi możliwe jest ponowne wykorzystanie klas w innych miejscach aplikacji bez tworzenia nadmiarowego kodu.

data class Person(val name: String, val surname: String, val bornYear: Int,
                  val address: Address,
                  val contactDetails: ContactDetails)
				  
data class Address(val city: String, val street: String, val houseNumber: String)

data class ContactDetails(val email: String, val phoneNumber: String) {

    fun isEmailValid(): Boolean {
        val pattern = Pattern.compile("^.+@.+\\..+$")
        val matcher = pattern.matcher(email)
        return matcher.matches()
    }

    fun isPhoneNumberValid(): Boolean {
        val pattern = Pattern.compile("^([+][0-9]{2})*[0-9]{9}\$")
        val matcher = pattern.matcher(phoneNumber)
        return matcher.matches()
    }
}

object SummaryGenerator {

    fun createSummary(person: Person): ByteArray {
        //create file with the data and return the bytes
        return byteArrayOf() //mock
    }
}

Open-close

Zasada otwarte-zamknięte (Open-close principle) głosi, że elementy systemu (klasy, moduły, funkcje) powinny być otwarte na rozszerzenia i zamknięte na modyfikacje. Oznacza to iż zmiana zachowania encji ma być możliwa w wyniku dodania nowego kodu, który nie zmienia struktury bieżącego ponieważ modyfikacja któregokolwiek elementu może spowodować awarię w innym miejscu. W wyniku tego sposób funkcjonowania systemu jest rozszerzony i pozostaje niezmieniony. Dzięki temu zmniejsza się ryzyko wprowadzenia błędu do aktualnie działającego oprogramowania oraz zachowuje zgodność z bieżącym zestawem testów wymagających jedynie uzupełnienia. Jest to szczególnie istotne w środowisku produkcyjnym np. biblioteki.

object Calculator {

    fun getArea(figure: Any): Double {
        return when (figure) {
            is Rectangle -> {
                figure.a * figure.b
            }
            is Triangle -> {
                val p = (figure.a + figure.b + figure.c) / 2
                sqrt(p * (p-figure.a) * (p-figure.b) * (p-figure.c))
            }
            is Circle -> {
                Math.PI * figure.r.pow(2.0)
            }
            else -> 0.0
        }
    }

    fun getCircumference(figure: Any): Double {
        return when (figure) {
            is Rectangle -> {
                2 * figure.a + 2 * figure.b
            }
            is Triangle -> {
                figure.a + figure.b + figure.c
            }
            is Circle -> {
                2 * Math.PI * figure.r
            }
            else -> 0.0
        }
    }
	
    //more methods
}

class Rectangle(val a: Double, val b: Double)

class Triangle(val a: Double, val b: Double, val c: Double)

class Circle (val r: Double)

Dodanie nowej klasy z możliwością obliczenia pola i obwodu lub modyfikacja bieżących może skutkować zmianą w klasie Calculator co nie pozwoli na rozszerzenie funkcjonalności bez naruszenia bieżącej struktury klasy. W takiej sytuacji należy posłużyć się wspólnym typem bazowym Figure oraz wymusić implementacje szczegółów w klasach pochodnych Rectangle, Triangle, Circle. Następnie w klasie Calculator wystarczy wywołać metody typu bazowego co pozwoli na dodanie nowych figur do kalkulatora bez konieczności modyfikacji klasy Calculator.

object Calculator {

    fun getArea(figure: Figure): Double {
        return figure.getArea()
    }

    fun getCircumference(figure: Figure): Double {
        return figure.getCircumference()
    }

    //more methods
}

class Rectangle(val a: Double, val b: Double) : Figure() {
    
    override fun getArea(): Double {
        return a * b
    }

    override fun getCircumference(): Double {
        return 2 * a + 2 * b
    }
}

class Triangle(val a: Double, val b: Double, val c: Double) : Figure() {
    
    override fun getArea(): Double {
        val p = (a + b + c) / 2
        return sqrt(p * (p-a) * (p-b) * (p-c))
    }

    override fun getCircumference(): Double {
        return a + b + c
    }
}

class Circle (val r: Double) : Figure() {

    override fun getArea(): Double {
        return Math.PI * r.pow(2.0)
    }

    override fun getCircumference(): Double {
        return 2 * Math.PI * r
    }
}

abstract class Figure {

    abstract fun getArea(): Double
    abstract fun getCircumference(): Double
    
    //more base common code
}

Liskov substitution

Zasada podstawienia Liskov (Liskov substitution principle) mówi, że 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 implementacji. Innymi słowy klasa dziedzicząca powinna rozszerzać funkcjonalność klasy bazowej bez dokonywania modyfikacji co pozwala na użycie w miejscu klasy bazowej dowolnej klasy pochodnej. Takie podejście wymaga zachowania zgodności interfejsu oraz metod.

class NotesView() {

    val notes = mutableListOf<Note>()

    fun init() {
        notes.add(AudioNote("audio", "description", File("audio.mp3"), "transcription"))
        notes.add(VideoNote("video", "description", File("video.mp4"), File("subtitles.txt")))
        notes.add(TextNote("text", "description", "content"))
    }

    fun playNote(index: Int) {
        notes(index).play()
        //this class know nothing about Note class implementations
        //for TextNote there will be problem
    }

    //more methods
}

class Note(val title: String, val description: String, val file: File) {

    //some media variables
    
    fun play() {
        //read file, prepare media playback and play
    }

    //more methods
}

class AudioNote(title: String, description: String, file: File, val transcription: String) 
    : Note(title, description, file)  {
    
    override fune play() {
        super.play()
        //add extra job like put audio transcription into view
    }
}

class VideoNote(title: String, description: String, file: File, val subtitles: File) 
    : Note(title, description, file)  {
    
    override fun play() {
        //load subtitles file
        super.play()
        //add extra job like combine video with subtitles
    }
}

class TextNote(title: String, description: String, val text) 
    : Note(title, description, File()) {
    
    override fun play() {
        //playing text is not supported so throw exception
        throw Exception("unsupported")
    }
}

Klient NotesView wykorzystuje zbiór obiektów typu Note w celu odtworzenia multimedialnej zawartości notatki bezpośrednio z widoku listy. Jednakże nie każdy obiekt rozszerzający typ bazowy posiada plik multimedialny. Przykładem takiej klasy jest TextNote której obiekty nie są zdolne do odtworzenia zawartości pomimo zgłoszonej deklaracji. NotesView nie musi znać szczegółów implementacji, oczekuje od wszystkich obiektów typu Note poprawnej implementacji i zdolności do wykonania zadania. Jednym z rozwiązań tego problemu może być stworzenie dodatkowego typu podstawowego dla notatek multimedialnych MediaNote rozszerzającego klasę Note, który przejmuje definicję funkcji play. Klient NotesView może teraz bez przeszkód wykorzystać wszystkie obiekty typu MediaNote do odtworzenia zawartości.

class NotesView() {

    val notes = mutableListOf<MediaNote>()

    fun init() {
        notes.add(AudioNote("audio", "description", File("audio.mp3"), "transcription"))
        notes.add(VideoNote("video", "description", File("video.mp4"), File("subtitles.txt")))
    }

    fun playNote(index: Int) {
        notes(index).play()
    }
}

class Note(val title: String, val description: String) {

    //some methods
}

class MediaNote(title: String, description: String, val file: File)
	: Note(title, description) {

    //some media variables
    
    fun play() {
        //read file, prepare media playback and play
    }
}

class AudioNote(title: String, description: String, file: File, val transcription: String) 
    : MediaNote(title, description, file)  {
    
    override fune play() {
        super.play()
        //add extra job like put audio transcription into view
    }
}

class VideoNote(title: String, description: String, file: File, val subtitles: File) 
    : MediaNote(title, description, file)  {
    
    override fun play() {
        //load subtitles file
        super.play()
        //add extra job like combine video with subtitles
    }
}

class TextNote(title: String, description: String, val text) 
    : Note(title, description) {
    
    //some methods
}

Interface segregation

Zasada segregacji interfejsów (Interface segregation principle) stwierdza, że żaden klient nie powinien być zmuszony do polegania na nieużywanych przez niego metodach. Realizacja tej zasady polega na dzieleniu dużych interfejsów na jak najmniejsze i szczegółowe dzięki czemu klient będzie mógł implementować tylko wymagane przez niego metody. Pozwala to na utrzymanie niezwiązanego systemu, łatwiejszego do refaktoryzacji i wprowadzania zmian.

class Exam() : Printable {

    //some variables and methods
    
    override fun printDocument() {
        //generate and save document
    }

    override fun printSheet() {
        //print sheet like xls is not supported
        throw Exception("not implemented")
    }
}

class ExamResults() : Printable {

    //some variables and methods
    
    override fun printDocument() {
        //generate and save document
    }

    override fun printSheet() {
        //generate and save sheet
    }
}

interface Printable {
    
    fun printDocument()
    fun printSheet()
}

Klasy Exam oraz ExamResults implementują interfejs Printable, który deklaruje możliwość generowania dokumentu tekstowego (printDocument) oraz arkusza kalkulacyjnego (printSheet). Jednakże eksportowanie obiektu klasy Exam do arkusza kalkulacyjnego nie ma sensu w związku z czym implementacja metody printSheet zgłosi wyjątek lub pozostanie pusta. Klient oczekujący stworzenia pliku arkusza kalkulacyjnego dla egzaminu spotka się z nieoczekiwanym zachowaniem lub wystąpieniem błędu. W związku z czym warto podzielić interfejs Printable na mniejsze dedykowane interfejsy PrintableDocument i PrintableSheet.

class Exam() : PrintableDocument {
    
    override fun printDocument() {
        //generate and save document
    }
}

class ExamResults() : PrintableDocument, PrintableSheet {

    override fun printDocument() {
        //generate and save document
    }

    override fun printSheet() {
        //generate and save sheet
    }
}

interface PrintableDocument {
    
    fun printDocument()
}

interface PrintableSheet {
    
    fun printSheet()
}

Dependency inversion

Zasada odwrócenia zależności (Dependency inversion principle) polega na tym, że elementy wysokiego poziomu nie powinny zależeć od jednostek niskiego poziomu, a zależności między nimi wynikają z abstrakcji. Abstrakcje nie powinny zależeć od szczegółów lecz to szczegóły powinny zależeć od abstrakcji. Mówiąc w skrócie zależności powinny w jak największym stopniu zależeć od abstrakcji, a nie konkretnej implementacji. Przeważnie zostaje to osiągnięte za pomocą interfejsów i klas abstrakcyjnych.

class Downloader {
    
    //make a lot of custom details code

    fun downloadByRest(param: String) {
        //create some rest client like Retrofit
        //call endpoint method
        //set callback for response
    }

    fun downloadBySocket(param: String) {
        //create socket and streams
        //open socket and write data message
        //listen for result
    }

    //more methods like upload etc
}

Klasa Downloader odpowiedzialna za pobieranie informacji z serwera dokonuje szczegółowej implementacji zachowania, polegając tym samym na zależnościach niskiego poziomu. Takie podejście nie jest wskazane, gdyż silnie łączy klasę wysokiego poziomu z niskopoziomowymi obiektami. Alternatywnym podejściem będzie wprowadzenie warstwy abstrakcji w postaci interfejsu Network wywoływanej z klienta Downloader. Szczegóły implementacji niskopoziomowych obiektów zostają przeniesione do klas RestNetwork i SocketNetwork implementujących interfejs Network co pozwala ukryć zależności niskiego poziomu przed klientem.

class Downloader(val network: Network) {
    
    //just use provided dependency without know any details

    fun fetch(param: String) {
        network.download(param)
    }
}

class RestNetwork : Network {

    //some fields and methods, implement details here
    
    override fun download(param: String) {
        //create some rest client like Retrofit
        //call endpoint method
        //set callback for response
    }
}

class SocketNetwork : Network() {

    //some fields and methods, implement details here
    
    override fun download(param: String) {
        //create socket and streams
        //open socket and write data message
        //listen for result
    }
}

interface Network {
    
    fun download(param: String)
    //define more methods like upload
}