quarta-feira, 4 de abril de 2012

SOLID - Single Responsability Principle

O principio da responsabilidade única é o primeiro do acrônimo S.O.L.I.D., um conjunto de princípios que auxiliam na criação de um software de qualidade. Single responsability é o princípio mais fácil de entender, mas o mais difícil de aplicar.

Não sou eu quem está dizendo. Quem diz isso, é Uncle Bob no seu livro Agile Principles, Patterns, and Practices in C# e na Object Mentor.

The SRP is one of the simplest of the principle, and one of the hardest to get right.

Como diz Uncle Bob, responsabilidade deve ser entendida como "razão para mudar". Se sua classe tem mais de um motivo para mudar ela tem mais de uma responsabilidade. E isso pode trazer problemas. Se você tem duas responsabilidades em um classe, elas estarão acopladas. Ela terá dois motivos para mudar. A alteração em uma pode impactar negativamente a outra.

Vamos refatorar uma solução para considerar o SRP. Muito código a partir de agora, então nada como o início de Smash do Offspring para começar:
Hi, It's time to relax (you know what that means)
A glass of wine, your favorite easy chair
A solução é relativamente simples. Criei um analisador de semelhança entre palavras, no estilo "você quis dizer?" Obviamente existem outras opções de implementação e possibilidade de utilizar soluções prontas da Internet. Mas a questão aqui não é a lógica para comparar, mas como o design pode ser  melhorado observando o SRP.

Como todo programador do século XXI antenado com as melhores práticas de desenvolvimento, vamos escrever nosso primeiro teste:

package br.com.celsomartins.lexical.tests;

import java.io.IOException;
import junit.framework.Assert;
import org.junit.Test;

public class WordAnalyserShould {
   
    @Test
    public void giveOptionsWithOneLetterMore() {
       
        String palavraDeTeste = "uibuntu";
       
        WordAnalyser analyser = WordAnalyser.instance(palavraDeTeste);
        String[] retorno = analyser.analyse();
           
        Assert.assertEquals("ubuntu", retorno[0]);
    }
}


Fazendo o teste compilar:

package br.com.celsomartins.lexical.analyser;

import java.io.IOException;

public class WordAnalyser {
   
    public WordAnalyser(String testWord) {    }

    public static WordAnalyser instance(String testWord) {
        return new WordAnalyser(testWord);
    }

    public String[] analyse() {
        return null;
    }
}



Fazendo o teste passar. Claro que pulei dois "baby steps" aqui - ver o teste falhar e escrever qualquer coisa para passar - a classe abaixo é o resultado final.

package br.com.celsomartins.lexical.analyser;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

public class WordAnalyser {
   
    private String word = "";

    public WordAnalyser(String testWord) {
        this.word = testWord;
    }

    public static WordAnalyser instance(String testWord) {
        return new WordAnalyser(testWord);
    }

    public String[] analyse() throws IOException {
        File file = new File("resource/words.txt");
        BufferedReader reader = new BufferedReader(new FileReader(file));
       
        String line = "";
        List<String> lineList = new ArrayList<String>();
        while ((line = reader.readLine()) != null) {
           
            if (word.equals(line)) {
                lineList.add(line);
               
            } else {
                char[] wordChars = word.toCharArray();
                char[] lineChars = line.toCharArray();
               
                int countInLine = 0, countInWord = 0;
               
                Integer matchCount = 0;
                while (countInWord < word.length() && countInLine < line.length()) {
                   
                    if (lineChars[countInLine] == wordChars[countInWord]) {
                        matchCount++;
                    } else {
                        if (line.length() > word.length()) {
                            countInLine++;
                        } else if (line.length() < word.length()) {
                            countInWord++;
                        }
                    }
                   
                    countInLine++; countInWord++;
                }
               
                System.out.println((matchCount.doubleValue() / word.length() * 100) + "% match");
                if ((matchCount.doubleValue() / word.length()) > 0.5) {
                    lineList.add(line);
                }
            }
        }
       
        return (String[]) lineList.toArray(new String[0]);
    }
}


Estamos prontos para escrever nossos últimos testes:

package br.com.celsomartins.lexical.tests;

import java.io.IOException;
import junit.framework.Assert;
import org.junit.Test;
import br.com.celsomartins.lexical.analyser.WordAnalyser;

public class WordAnalyserShould {
   
    @Test
    public void giveOptionsWithOneLetterMore() {
       
        String palavraDeTeste = "uibuntu";
       
        try {
            WordAnalyser analyser = WordAnalyser.instance(palavraDeTeste);
            String[] retorno = analyser.analyse();
           
            Assert.assertEquals("ubuntu", retorno[0]);
           
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
   
    @Test
    public void giveOptionsWithOneLetterLess() {
        String palavraDeTeste = "uuntu";
       
        try {
            WordAnalyser analyser = WordAnalyser.instance(palavraDeTeste);
            String[] retorno = analyser.analyse();
           
            Assert.assertEquals("ubuntu", retorno[0]);
           
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
   
    @Test
    public void giveOptionsWithOneDifferenceWithTheSameLength() {
        String palavraDeTeste = "uvuntu";
       
        try {
            WordAnalyser analyser = WordAnalyser.instance(palavraDeTeste);
            String[] retorno = analyser.analyse();
           
            Assert.assertEquals("ubuntu", retorno[0]);
           
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}


Existem outros testes, como garantir que a classe se comporta da forma correta se receber parametros nulos no seu construtor, mas esse não é o escopo deste post.

Bem, espero que até aqui, sem novidades, certo?

Podemos notar que o método analyse() possui algumas responsabilidades, como ler do arquivo de dados, decompor, iterar e comparar. Como diz Uncle Bob, " a class or module should have one, and only one, reason to change". Além disso, temos alguns aninhamentos de estruturas condicionais e laços, deixando o método grande, o que dificulta a leitura do código. Então vamos ao nosso primeiro refactoring.

O teste viola o DRY e como "duplication is evil", vamos começar por ele:

package br.com.celsomartins.lexical.tests;

import java.io.IOException;
import junit.framework.Assert;
import org.junit.Test;
import br.com.celsomartins.lexical.analyser.WordAnalyser;

public class WordAnalyserShould {
   
    @Test
    public void giveOptionsWithOneLetterMore() {
        String palavraDeTeste = "uibuntu";
        testAnalyse(palavraDeTeste);
    }

    @Test
    public void giveOptionsWithOneLetterLess() {
        String palavraDeTeste = "uuntu";
        testAnalyse(palavraDeTeste);
    }
   
    @Test
    public void giveOptionsWithOneDifferenceWithTheSameLength() {
        String palavraDeTeste = "uvuntu";
        testAnalyse(palavraDeTeste);
    }
   
    private void testAnalyse(String palavraDeTeste) {
        try {
            WordAnalyser analyser = WordAnalyser.instance(palavraDeTeste);
            String[] retorno = analyser.analyse();
          
            Assert.assertEquals("ubuntu", retorno[0]);
          
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}


Simplesmente a duplicação foi removida para um método auxiliar. No Eclipse, selecionar a porção do código e pressionar SHIFT+ALT+M já abre o refactoring "Extract Method" da IDE e, quando o método é criado, todas as ocorrências semelhantes já são substituidas.

Agora vamos a nossa classe de negócio.

Analisando o método, percebi que dois whiles eram responsáveis por ler a linha no arquivo E comparar os caracteres. O conector torna bem fácil a percepção de que este método faz demais, tem mais de uma razão para ser alterado.

Como o centro desta solução é a comparação para obter alguns resultados semelhantes, parece que o domínio pede uma classe WordComparator. Esta classe ficou tão interessante (coesa) que não foram necessários getters e/ou setters.

package br.com.celsomartins.lexical.analyser;

public class WordComparator {
   
    private String wordOne;
    private String wordTwo;

    public static WordComparator getComparator(String wordOne, String wordTwo) {
        return new WordComparator(wordOne, wordTwo);
    }
   
    private WordComparator(String wordOne, String wordTwo) {
        this.wordOne = wordOne;
        this.wordTwo = wordTwo;
    }
   
    public Integer compare() {
       
        char[] wordOneChars = wordOne.toCharArray();
        char[] wordTwoChars = wordTwo.toCharArray();

        Integer countInWordTwo = 0, countWordOne = 0;

        Integer matchCount = 0;

        while (countWordOne < wordOne.length() && countInWordTwo < wordTwo.length()) {

            if (wordTwoChars[countInWordTwo] == wordOneChars[countWordOne]) {
                matchCount++;
                countInWordTwo++; countWordOne++;

            } else if (wordOne.length() == wordTwo.length()) {
                countInWordTwo++; countWordOne++;
              
            } else if (wordTwo.length() > wordOne.length()) {
                countInWordTwo++;

            } else if (wordTwo.length() < wordOne.length()) {
                countWordOne++;
            }
        }
        return matchCount;
    }
}


Pensei em implementar a interface Comparator<T>, mas como ela obriga a criação de um compare() passando dois argumentos, achei que seria melhor apenas manter o padrão do nome do método. Os dois atributos da classe fazem sentido e a regra de redução de parâmetros dos métodos do Clean Code, também do Uncle Bob, me pareceu muito mais adequada a este contexto.

Observe como faz sentido esta classe e estes atributos. Como faz sentido receber os dois no construtor. Não existe um comparador de duas strings se você não tem duas strings para comparar. Então agora temos uma classe com apenas uma responsabilidade, imutável e coesa.

Ainda tem muito aninhamento, mas podemos reparar que são muito simples e extremamente pequenos. O método é pequeno. Então a leitura não está prejudicada.

Separar essas responsabilidades não foi trivial e não usei (não consegui usar) as ferramentas de refactoring do Eclipse. Manualmente coloquei a responsabilidade de comparar os caracteres na nova classe, WordComparator, e tirei da classe WordAnalyser. Continuei com pequenos refactorings, e WordAnalyser ficou assim:

package br.com.celsomartins.lexical.analyser;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;

public class WordAnalyser {
   
    private String word = "";

    public WordAnalyser(String testWord) {
        this.word = testWord;
    }

    public static WordAnalyser instance(String testWord) {
        return new WordAnalyser(testWord);
    }

    public String[] analyse() throws IOException {
        File file = new File("resource/words.txt");
        BufferedReader reader = new BufferedReader(new FileReader(file));
       
        String line = "";
        List<String> lineList = new ArrayList<String>();

        while ((line = reader.readLine()) != null) {
            if (word.equals(line) || checkChars(line)) {
                lineList.add(line);
            }
        }
       
        return (String[]) lineList.toArray(new String[0]);
    }

    private Boolean checkChars(String line) {
       
        WordComparator comparator = WordComparator.getComparator(word, line);
        Integer matchCount = comparator.compare();
       
        System.out.println(new DecimalFormat("#0.00").format(
                matchCount.doubleValue() / word.length() * 100) + "% match");
       
        return ((matchCount.doubleValue() / word.length()) > 0.5);
    }
}


A classe ficou muito menor e os métodos ficaram muito menores. Existem melhorias, como parametrizações de decisões (porcentagem para considerar semelhante, local do arquivo de dados, separação da leitura de arquivos), mas estão fora do escopo deste post.

A cada refactoring, rodei os testes para garantir o funcionamento. Também é interessante cobrir compare() de WordComparator com testes próprios, que não dependam de leitura de arquivo em disco. Acoplamento do teste com a leitura em disco deve ser evitado, pelo mesmo motivo do acoplamento com BD. Assim, podemos criar um repositório e injetar os dados deste arquivo. Mas é assunto para o post sobre Dependency Injection.

Projeto: https://github.com/celsoMartins/lexical

S - Single Responsability Principle
O - Open-Closed Principle
L - Liskov Substitution Principle
I - Interface Segregation Principle
D - Dependency Injection Principle

Nenhum comentário:

Postar um comentário