DRY

Zasady projektowe

  |   16 min czytania

Wstęp

DRY (Don't Repeat Yourself) to reguła mówiąca o tym, że każdy fragment wiedzy powinien mieć jedną i jednoznaczną reprezentacje w systemie. Zaleca unikanie wszelkiego rodzaju powtórzeń czynności wykonywanych przez zespół deweloperski w procesie wytwarzania oprogramowania. Dotyczy zatem nie tylko powtórzeń kodu źródłowego lecz także innych zadań zawierających się w procesie tworzenia i konfiguracji projektu np. w trakcie kompilacji czy tworzenia dokumentacji. Może być rozpatrywana w jeszcze szerszym kontekście dążenia do automatyzacji procesów, które pozwalają na redukcje powtarzających się czynności manualnych. W przypadku kodu źródłowego stosowane są różne techniki eliminujące powtórzenia na wielu poziomach struktury kodu. Złamanie zasady DRY dotyczy przede wszystkim duplikacji dziedziny wiedzy, niekoniecznie duplikatu samego kodu. System niespełniający reguły DRY w sposób zadowalający może być nazywany terminem WET (Write Everything Twice, We Enjoy Typing).

Zalety

Stosowanie się do zasady DRY umożliwia przede wszystkim uniknięcie problemów wynikających z błędów popełnionych w powtarzającym się kodzie. W przypadku znalezienia wadliwego fragmentu wystarczy zmiana w jednym miejscu zamiast w każdym powtarzającym się bloku. Podobna zależność zachodzi dla modyfikacji wybranego bloku kodu. Pozwala to na optymalizację kosztów poniesionych w procesach diagnozy, naprawy, rozwoju i testowania oprogramowania. Ponadto implikacja reguły wymusza w pewnym stopniu specyfikacje funkcjonalności i interfejsów dążąc do rozwiązań uniwersalnych.

Zagrożenia

Nie warto na siłę próbować implementować zasadę DRY ponieważ może się zdarzyć, że na pozór identyczne kody różnią się szczegółem lub realizują różną funkcjonalność, która możę w przyszłości ulec zmianie. Dlatego przed podjęciem dalszych kroków należy upewnić się o słuszności decyzji. Dobrą praktyką jest pisanie czytelnego i rozwijalnego kodu o krótkich funkcjach z jedną odpowiedzialnością. Jednakże pomimo tego należy odpowiednio wyważyć jego atomowość unikając zbyt dużego podziału, który może zwiększyć jego złożoność i powiązanie. Jeśli dany kod istnieje w jednym miejscu być może dobrym rozwiązaniem będzie wstrzymanie się z wydzieleniem go do osobnej funkcji do momentu hipotetycznego drugiego wystąpienia.

Funkcje

Oczywistym sposobem na zmniejszenie ilości powtórzeń jest umieszczanie bloku kodu w ciele funkcji, która może zostać użyta wielokrotnie w wielu miejscach aplikacji. Należy także wziąć pod uwagę czy istnieje możliwość i zachodzi sens szukania rozwiązania generalnego poprzez funkcję z parametrami wejściowymi.

class RegisterView {

    fun register() {
        //get inputs from view
        val input = "Password123" //mock
        val reinput = "Password123"
        val email = "example@androidcode.pl"
		
        if(arePasswordsValid(input, reinput) && isEmailValid(email)) {
            //make register call
        }
        else {
            //show validation error
        }
    }

    fun arePasswordsValid(password: String, repassword: String): Boolean {
        val pattern = Pattern.compile("^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9]).{6,}$")
        val matcher = pattern.matcher(password)
        return matcher.matches() && password == repassword

    }

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

class ChangePasswordView {

    fun changePassword() {
        //get inputs from view
        val input = "Password123" //mock
        val reinput = "Password123"
		
        if(isPasswordValid(input) && input == reinput) {
            //make register call
        }
        else {
            //show validation error
        }
    }
    
    fun isPasswordValid(password: String): Boolean {
        val pattern = Pattern.compile("^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9]).{6,}$")
        val matcher = pattern.matcher(password)
        return matcher.matches()
    }
	
    //some body
}

Walidacja hasła zachodzi w ten sam sposób w conajmniej dwóch miejscach: RegisterView, ChangePasswordView. Ponadto widok rejestracji sprawdza poprawność wpisanego adresu email. Można przypuszczać, że istnieje spora szansa na ponowne wykorzystanie metod walidacyjnych w innych miejscach aplikacji. W związku z tym warto przenieść metody walidacyjne do jednej klasy pomocniczej Validator dodatkowo zachowując ich spójność w całym kodzie.

class ResetPasswordView {

    fun register() {
        //get inputs from view
        val input = "Password123" //mock
        val reinput = "Password123"
        val email = "example@androidcode.pl"
        
        if(Validator.arePasswordsValid(input, reinput) && Validator.isEmailValid(email)) {
            //make register call
        }
        else {
            //show validation error
        }
    }
    
    //some body
}

class ChangePasswordView {

    fun changePassword() {
        //get inputs from view
        val input = "Password123" //mock
        val reinput = "Password123"
		
        if(Validator.arePasswordsValid(input, reinput)) {
            //make register call
        }
        else {
            //show validation error
        }
    }
	
    //some body
}

object Validator {

    fun isPasswordValid(password: String): Boolean {
        val pattern = Pattern.compile("^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9]).{6,}$")
        val matcher = pattern.matcher(password)
        return matcher.matches()
    }

    fun arePasswordsValid(password: String, repassword: String): Boolean {
        return isPasswordValid(password) && password == repassword
    }

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

Stałe

Warto rozważyć zastosowanie zarówno stałych lokalnych jak i globalnych, które poza redukcją powielania zwiększają czytelność kodu w wyniku zastępienia niekoniecznie zrozumiałych nazw zmiennych.

class DateUtils {

    fun getDaysInMiliseconds(miliseconds: Long): Long {
        return miliseconds / 1000 * 60 * 60 * 24
    }
    
    fun getMilisecondsInDays(days: Long): Long { 
        return days * 1000 * 60 * 60 * 24
    }
}

Metody klasy pomocnicznej DateUtils wykorzystują w swoich obliczeniach mnożnik reprezentujący ilość milisekund w dniu. Aby zaoszczędzić powielenia kodu i uniknąć pomyłki należy stworzyć stałą MILISECONDS_IN_DAYS i wykorzystać ją w metodach.

class DateUtils {
    
    companion object {
        const val MILISECONDS_IN_DAYS: Long = 1000 * 60 * 60 * 24
    }

    fun getDaysInMiliseconds(miliseconds: Long): Long {
        return miliseconds / MILISECONDS_IN_DAYS
    }

    fun getMilisecondsInDays(days: Long): Long {
        return days * MILISECONDS_IN_DAYS
    }
}

Typy

Wykonanie żądanej operacji może się wiązać z przekazaniem argumentów i zwróceniem wyniku, które mogą się składać z kilku obiektów. W takiej sytuacji zasadnym może być stworzenie nowego typu danych zawierającego obiekty typów argumentów wejściowych lub wyjściowych.

class PlayerManager {

	fun changeNick(id: Long, newNick: String) {
        //put newNick to database or send to server
    }

    fun getLevel(points: Int): String {
        return when(points) {
            in 0..99 -> "BEGINNER"
            in 100..300 -> "JUNIOR"
            in 300..599 -> "ADVANCED"
            else -> "MASTER"
        }
    }
	
	fun increasePoints(id: Long, points: Int, percent: Int) {
        val newPoints = points + points * percent
        updateLevel(id, newPoints)
        //put newPoints to database or send to server using id
    }
	
	private fun updateLevel(id: Long, points: Int) {
        val newLevel = getLevel(points)
        //put newLevel to database or send to server using id
    }
}

Metody klasy PlayerManager operują na wielu parametrach różnego typu w celu zastosowania operacji na danych zawodnika. Wymienione argumenty są ze sobą logicznie powiązane i mogą składać się na pola klasy Player. Wówczas część odpowiedzialności z klasy PlayerManager mogłaby zostać przeniesiona do klasy Player.

class PlayerManager {

    fun changeNick(player: Player, newNick: String) {
        player.nick = newNick
        //put player to database or send to server
    }
    
    fun increasePoints(player: Player, percent: Int) {
        player.increasePointsByPercent(percent)
        //put player to database or send to server
    }
}

data class Player(val id: Long, var nick: String, var points: Int, var level: Level) {

    init {
        updateLevel()
    }

    fun increasePointsByPercent(percent: Int) {
        points += points * percent
        updateLevel()
    }

    fun updateLevel() {
        level = when(points) {
            in 0..99 -> Level.BEGINNER
            in 100..300 -> Level.JUNIOR
            in 300..599 -> Level.ADVANCED
            else -> Level.MASTER
        }
    }

    enum class Level(value: String) {
        BEGINNER("BEGINNER"),
        JUNIOR("JUNIOR"),
        ADVANCED("ADVANCED"),
        MASTER("MASTER")
    }
}

Polimorfizm i dziedziczenie

Polimorfizm umożliwia spójne wykorzystanie elementów na kilka różnych sposobów niezależnie od ich typów za pomocą jednego wspólnego interfejsu. Eliminuje instrukcje warunkowe zależne od typu i redukuje powtarzający się kod. Mechanizm dziedziczenia pozwala na wykorzystanie współdzielonego kodu typu bazowego przez jego podtypy.

class Book(val index: Long, val title: String, val description: String, val year: Int,
                val author: String, val topics: List<String>) {

    fun getSummary(): String {
        return "$title ($year) \n $description"
    }

    fun print() {
        //get Book from database by index and print to file
    }
}

class Article(val index: Long, val title: String, val description: String, val year: Int,
              val author: String, val pages: Int) {

    fun getSummary(): String {
        return "$title ($year) \n $description"
    }

    fun print() {
        //get Article from database by index and print to file
    }
}

class Film(val index: Long, val title: String, val description: String, val year: Int,
           val director: String, val actors: List<Actor>, val duration: Int) {

    fun getSummary(): String {
        return "$title ($year) \n $description"
    }
}

class Music(val index: Long, val title: String, val description: String, val year: Int,
            val band: String, val duration: Int) {

    fun getSummary(): String {
        return "$title ($year) \n $description"
    }
}

Klasy Book, Article, Film, Music posiadają właściwości i cechy wspólne, które powinny zostać wyabstrachowane do typu wspólnego Item. Ponadto klient wykorzystujący te obiekty jest zmuszony do sprawdzania typów i rzutowania, aby dokonać wydruku elementów papierowych. Zachowanie opisujące możliwość wydruku może zostać przeniesione do interfejsu Printable i jego implementacji w wybranych klasach.

class Book(index: Long, title: String, description: String, year: Int,
                val author: String, val topics: List<String>) : Item(index, title, description, year), Printable {

    override fun print() {
        //get Book from database by index and print to file
    }
}

class Article(index: Long, title: String, description: String, year: Int,
              val author: String, val pages: Int) : Item(index, title, description, year), Printable {

    override fun print() {
        //get Article from database by index and print to file
    }
}

abstract class Item (val index: Long, val title: String, val description: String, val year: Int) {

    fun getSummary(): String {
        return "$title ($year) \n $description"
    }
}

class Film(index: Long, title: String, description: String, year: Int,
           val director: String, val actors: List<Actor>, val duration: Int) : Item(index, title, description, year)
		   
class Music(index: Long, title: String, description: String, year: Int,
            val band: String, val duration: Int) : Item(index, title, description, year)

interface Printable {

    fun print()
}

Moduły

Dzielenie kodu na moduły umożliwia nie tylko wyróżnienie części logicznych i funkcjonalnych aplikacji, ale może ułatwić ponowne użycie danej funkcjonalności w innym projekcie. W szczególności są to zewnętrzne biblioteki i wtyczki.

class Calculator {

    fun sqrt(value: Double): Double {
        return value * value
    }

    fun doubleSqrt(value: Double): Double {
        return 2 * sqrt(value)
    }

    fun pow(value: Double, exponent: Int): Double {
        return if(exponent == 0)
            1.0
        else {
            var result = value
            for(i in exponent downTo 1) {
                result *= value
            }
            result
        }
    }
    
    fun doublePow(value: Double, exponent: Int): Double {
        return 2 * pow(value, exponent)
    }
}

Klasa Calculator dostarcza zbiór metod wykonujących obliczenia matematyczne. Część definicji zawiera się jednak w pakiecie matematycznym Math w związku z czym mogą one zostać wykorzystane zamiast autorskiej implementacji.

class Calculator {

    //use Math module instead of own functions
    
    fun doubleSqrt(value: Double): Double {
        return 2 * Math.sqrt(value)
    }

    fun doublePow(value: Double, exponent: Int): Double {
        return 2 * Math.pow(value, exponent.toDouble())
    }
}