KISS

Zasady projektowe

  |   12 min czytania

Wstęp

KISS (Keep it Simple, Stupid) to reguła powstała w środowisku inżynierii wojskowej, która została zaadaptowana do wielu dziedzin życia w tym szczególnie inżynierii, nauki i życia społecznego. Zakłada tworzenie i projektowanie produktów w tak prosty sposób, aby każdy przeciętny użytkownik potrafił się posługiwać i rozumieć sposób działania, a inżynier mógł podjąć się naprawy w przeciętnych warunkach. Istnieje wiele wariantów rozwinięcia skrótu w tym m.in.: Keep it Super Simple, Keep it Simple, Smart i wszystkie z nich mają charakter pozytywny akcentując utrzymywanie produktów w sposób prosty i zrozumiały nawet dla potocznego głupka.

Programowanie

Adaptacja zasady KISS w informatyce oraz inżynierii oprogramowania odnosi się do każdego elementu oprogramowania, począwszy od procesu projektowania struktury programu, interfejsu użytkownika, skończywszy na kodzie źródłowym. Projekt powinien być tworzony i rozwijany w taki sposób, aby był jak najbardziej zrozumiały, bez wprowadzania niepotrzebnych skomplikowanych szczegółów. Zespół deweloperski czy programista przejmujący projekt powinien bez większego wysiłku być w stanie zrozumieć strukturę, sposób działania i szczegóły implementacji. Subiektywną miarą reprezentującą czytelność i prostotę projektu mogą być właśnie koszty poniesione przez programistów próbujących zrozumieć kod innego programisty.

Utrzymanie

Każdy projekt, który z założenia ma być utrzymywany i rozwijany w przyszłości powinien stosować się możliwie często do reguły KISS. Z biegiem czasu skład zespołu deweloperskiego może ulec zmianie co nie powinno być przeszkodzą w dalszym rozwoju projektu. Dlatego ważnym jest tworzenie projektu w taki sposób, aby mógł być łatwo zrozumiały przez każdego programiste danej firmy. W tym celu pomocne może być przyjęcie wspólnych wytycznych i standardów kodu w obrębie zespołu bądź zespołów.

//don't mix guidelines, harder to maintain and understand by team
class NumberManager(private var number: Int) {
    
    companion object {
        const val ONE_HUNDRED = 100
    }

    fun multiplyNumber(value: Int) {
        number *= value
    }
}

class number_manager(private var mNumber: Int) {

    companion object {
        const val oneHundred = 100
    }

    fun multiply_number(value : Int) {
        mNumber = mNumber * value
    }
}

Refaktor

Proces refaktoryzacji wpisuje się w trend zasady KISS. W trakcie rozwoju projektów zachodzi wiele modyfikacji nawet w klasach właściwie zaprojektowanych. Rodzi to potrzebę ciągłego dbania o jakość kodu. Jeśli jest to możliwe należy zastany kod uprościć i zostawić czytelniejszym.

Struktura

Warto dbać o spójność projektu w zastosowaniu jednolitych wzorców architektonicznych. Poszukiwanie najlepszego rozwiązania dla danego problemu nierzadko jest oparte na metodzie prób i błędów. Podążając za biężącymi trendami należy utrzymać jeśli to możliwe jedną architekturę przynajmniej dla modułu. Pozwoli to na uniknięcie wymagania znajomości wielu wzorców przez każdego programistę i zmniejszy jego złożoność. Ponadto należy utrzymać spójną strukturę pakietów.

Nazewnictwo

Duże znaczenie dla zrozumienia struktury projektu oraz szczegółów implementacji ma nazewnictwo. Należy dobierać odpowiednie nazwy dla wszystkich elementów projektu: pakietów, klas, obiektów, metod, zmiennych, stałych, parametrów itd. Nazwy powinny jednoznacznie opisywać przeznaczenie i odpowiedzialność oraz jednocześnie nie być zbyt długie. Dobrą praktyką jest przyjęcie spójnej notacji i konwencji nazewnictwa w obrębie całego zespołu.

//what this class can do? it suggests to store time data
object Time {

    const val MILISECONDS: Long = 36000 //what it is?

    //smaller than what? and what is time?
    fun isTimeSmaller(value: Long): Boolean {
        return value < System.currentTimeMillis()
    }

    //smaller than what?
    fun isDateSmaller(value: Date): Boolean {
        return value.time < System.currentTimeMillis()
    }

    //what does it mean?
    fun daysTime(value: Long): Long {
        return value / MILISECONDS
    }

    //what does it mean?
    fun daysDate(value: Date): Long {
        return value.time / MILISECONDS
    }
}

//names explain the purpose
object DateUtils {

    const val MILISECONDS_IN_HOURS: Long = 1000 * 60 * 60

    fun isPast(timestamp: Long): Boolean {
        return timestamp < System.currentTimeMillis()
    }

    fun isPast(date: Date): Boolean {
        return date.time < System.currentTimeMillis()
    }

    fun getFullDays(timestamp: Long): Long {
        return timestamp / MILISECONDS_IN_HOURS
    }

    fun getFullDays(date: Date): Long {
        return date.time / MILISECONDS_IN_HOURS
    }
}

Komentarze

Dobry kod powinien być napisany w taki sposób, aby komentarze nie były wymagane do jego zrozumienia. Jeśli powstaje potrzeba dodawania komentarzy do szczegółów implementacji należy się zastanowić czy możliwe jest uproszczenie opisywanego kodu. Być może dobrym pomysłem jest podzielenie go na mniejsze fragmenty, których nazewnictwo tłumaczy ich wykorzystanie. Są jednak sytuacje w których zastosowanie komentarzy ma sens i jest wręcz niezbędne np. w sytuacji opisania warunków zewnętrznych, miejsc krytycznych lub know-how.

//a lot of comments explanations of steps
object OrderUtils {
    
    fun calculateSum(products: List<Product>, promotions: List<Promotion>): Double {
        //calculate sum of products
        var sum = 0.0
        for(product in products) {
            sum += product.price
        }

        //calculate total discount from prioritized promotions
        var discount = 0.0
        for(promotion in promotions) {
            //promotions can be amount or percent only
            if(promotion.amount > 0) {
                //amount promotion can by apply only when total sum is at least 10 zloty
                if(sum - (discount + promotion.amount) > 10.0) {
                    discount += promotion.amount
                }
            }
            else if(promotion.percent > 0) {
                //amount promotion can by apply only when total sum is at least 20 zloty
                if(sum - (discount + sum * promotion.percent) > 20.0) {
                    discount += sum * promotion.percent
                }
            }
        }

        return sum - discount
    }
}

//only single business comment, code is readable
object OrderUtilsFixed {

    //min amounts set by marketing department
    const val PROMOTION_AMOUNT_MIN_PRICE = 10.0
    const val PROMOTION_PERCENT_MIN_PRICE = 20.0

    fun calculateSum(products: List<Product>, promotions: List<Promotion>): Double {
        val productsPrice = getProductsPrice(products)
        return productsPrice - getDiscountAmount(productsPrice, promotions)
    }

    fun getProductsPrice(products: List<Product>): Double {
        var sum = 0.0
        for(product in products) {
            sum += product.price
        }
        return sum
    }

    fun getDiscountAmount(productsPrice: Double, promotions: List<Promotion>): Double {
        var discount = 0.0
        for(promotion in promotions) {
            if(canApplyPromotionAmount(promotion.amount, productsPrice, discount)) {
                discount += promotion.amount
            }
            else if(canApplyPromotionPercent(promotion.percent, productsPrice, discount)) {
                discount += productsPrice * promotion.percent
            }
        }
        return discount
    }

    fun canApplyPromotionAmount(amount: Double, productsPrice: Double, currentDiscount: Double): Boolean {
        return amount > 0 && productsPrice - (currentDiscount + amount) > PROMOTION_AMOUNT_MIN_PRICE
    }

    fun canApplyPromotionPercent(percent: Int, productsPrice: Double, currentDiscount: Double): Boolean {
        return percent > 0 && productsPrice - (currentDiscount + productsPrice * percent) > PROMOTION_PERCENT_MIN_PRICE
    }
}

YAGNI

Reguła YAGNI (You Ain't Gonna Need) mówi o tym, aby w momencie tworzenia kodu umieszczać w nim tylko to co jest lub będzie na pewno potrzebne. Zastosowanie reguły po części realizuje zasadę KISS, tzn. niepotrzebny i nieużywany kod mógłby utrudnić zrozumienie projektu. Istnieje spora szansa, że nieużywane fragmenty i funkcjonalności, które powstają przy okazji realizacji innych zadań w przyszłości mogą być zupełnie niepotrzebne lub wymagania zmienią się na tyle, że wymuszą zmianę implementacji. W związku z czym pisanie kodu na przyszłość (np. zestawu funkcji walidacyjnych) może okazać się stratą czasu. Biorąc pod uwagę zasady KISS i YAGNI należy zastanowić się nad bilansem zysków i strat dla wprowadzania rozwiązań uniwersalnych bez istnienia żadnych alternatywnych typów w momencie tworzenia.

object Validator {

    //at this moment there is only need to valid password and email
    //for register and login purpose
    
    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 isEmailValid(email: String): Boolean {
        val pattern = Pattern.compile("^.+@.+\\..+$")
        val matcher = pattern.matcher(email)
        return matcher.matches()
    }

    //don't make polish phone number validator, it costs time to create regex
    //there is no need to use it in app at this moment
    fun isPhoneNumberValid(number: String): Boolean {
        val pattern = Pattern.compile("(?<!\\w)(\\(?(\\+|00)?48\\)?)?[ -]?\\d{3}[ -]?\\d{3}[ -]?\\d{3}(?!\\w)")
        val matcher = pattern.matcher(number)
        return matcher.matches()
    }
}

Testowanie

Dobra praktyką w metodyce testów jest testowanie jak najmniejszej jednostki. W trakcie tworzenia przypadków testów dąży się do uproszczenia problemów do zadań atomowych. Im mniejsze i prostsze metody testowe tym lepiej dla jakości testów.

//all tests in single function could be no good idea
class ValidatorTest {

    @Test
    fun validatePasswords() {
        assertTrue(Validator.isPasswordValid("Abcde0"))
        assertTrue(Validator.isPasswordValid("1234Ab"))
        assertTrue(Validator.isPasswordValid("Abc d0"))
        assertFalse(Validator.isPasswordValid(""))
        assertFalse(Validator.isPasswordValid("Abcd0"))
        assertFalse(Validator.isPasswordValid("abcdef"))
        assertFalse(Validator.isPasswordValid("123456"))
        assertFalse(Validator.isPasswordValid("ABCDEF"))
    }
}

//make smaller test functions to check many use cases
class ValidatorTestFixed {    

    @Test
    fun checkValidPasswords() {
        assertTrue(Validator.isPasswordValid("Abcde0"))
        assertTrue(Validator.isPasswordValid("1234Ab"))
        assertTrue(Validator.isPasswordValid("Abc d0"))
    }

    @Test
    fun checkEmptyPassword() {
        assertFalse(Validator.isPasswordValid(""))
    }

    @Test
    fun checkToShortPassword() {
        assertFalse(Validator.isPasswordValid("Abcd0"))
    }

    @Test
    fun checkOnlySmallLettersPassword() {
        assertFalse(Validator.isPasswordValid("abcdef"))
    }

    @Test
    fun checkOnlyBigLettersPassword() {
        assertFalse(Validator.isPasswordValid("ABCDEF"))
    }

    @Test
    fun checkOnlyNumbersPassword() {
        assertFalse(Validator.isPasswordValid("123456"))
    }
}

Dokumentacja

Dokumentacja projektowa również powinna być tworzona w taki sposób, aby była zrozumiała dla potencjalnych odbiorców. Na jej podstawie programista powinien być zdolny do zastosowania funkcjonalności i ewentualnego rozszerzenia projektu. Niewątpliwie dobrze i prosto napisany interfejs znacznie ułatwia tworzenie zrozumiałej dokumentacji.