Padrão de iterador - por que é importante não expor a representação interna?

24

Estou lendo C # Design Pattern Essentials . Atualmente, estou lendo sobre o padrão do iterador.

Compreendo perfeitamente como implementar, mas não entendo a importância ou vejo um caso de uso. No livro, é apresentado um exemplo em que alguém precisa obter uma lista de objetos. Eles poderiam ter feito isso expondo uma propriedade pública, como IList<T>ou Array.

O livro escreve

O problema é que a representação interna em ambas as classes foi exposta a projetos externos.

Qual é a representação interna? O fato de ser um arrayou IList<T>? Eu realmente não entendo por que isso é uma coisa ruim para o consumidor (o programador chamando isso) saber ...

O livro então diz que esse padrão funciona expondo sua GetEnumeratorfunção, para que possamos chamar GetEnumerator()e expor a 'lista' dessa maneira.

Presumo que esses padrões tenham um lugar (como todos) em determinadas situações, mas não consigo ver onde e quando.

MyDaftQuestions
fonte
11
Outras leituras: en.m.wikipedia.org/wiki/Law_of_Demeter
Jules
4
Porque, a implementação pode querer mudar de usar uma matriz para usar uma lista vinculada sem exigir alterações na classe de consumidor. Isso seria impossível se o consumidor souber a classe exata retornada.
usar o seguinte código
11
Não é uma coisa ruim para o consumidor saber , é uma coisa ruim para o consumidor confiar . Não gosto do termo ocultação de informações, pois leva você a acreditar que as informações são "privadas", como sua senha, e não privadas, como a parte interna de um telefone. Os componentes internos estão ocultos porque são irrelevantes para o usuário, não porque são algum tipo de segredo. Tudo o que você precisa saber é marcar aqui, conversar aqui, ouvir aqui.
Captain Man
Suponha que tenhamos uma "interface" agradável Fque me dê conhecimento (métodos) de a, be c. Tudo está bem, pode haver muitas coisas diferentes, mas são apenas Fpara mim. Pense no método como "restrições" ou cláusulas de um contrato que as Fclasses comprometam a fazer. Suponha que adicionemos um dporém, porque precisamos dele. Isso adiciona uma restrição adicional, cada vez que fazemos isso, impomos mais e mais aos Fs. Eventualmente (na pior das hipóteses), apenas uma coisa pode ser uma, Fentão podemos também não tê-la. E Frestringe tanto que há apenas uma maneira de fazê-lo.
Alec Teal
@ captainman, que comentário maravilhoso. Sim, vejo por que abstrair muitas coisas, mas a reviravolta em conhecer e confiar é ... Francamente ... Brilhante. E uma distinção importante que eu não considerei até ler sua postagem.
MyDaftQuestions

Respostas:

56

Software é um jogo de promessas e privilégios. Nunca é uma boa idéia prometer mais do que você pode cumprir ou mais do que o seu colaborador precisa.

Isso se aplica particularmente aos tipos. O objetivo de escrever uma coleção iterável é que seu usuário possa iterar sobre ela - nem mais, nem menos. Expor o tipo concretoArray geralmente cria muitas promessas adicionais, por exemplo, que você pode classificar a coleção por uma função de sua própria escolha, sem mencionar o fato de que uma normal Arrayprovavelmente permitirá que o colaborador altere os dados armazenados nela.

Mesmo se você acha que isso é uma coisa boa ("Se o renderizador notar que a nova opção de exportação está faltando, ela pode ser corrigida! Legal!"), No geral, isso diminui a coerência da base de código, dificultando raciocinar sobre - e tornar o código fácil de raciocinar é o principal objetivo da engenharia de software.

Agora, se seu colaborador precisar acessar várias coisas para garantir que não perca nenhuma delas, implemente uma Iterableinterface e exponha apenas os métodos declarados por essa interface. Dessa forma, no próximo ano, quando uma estrutura de dados altamente melhor e mais eficiente aparecer em sua biblioteca padrão, você poderá alterar o código subjacente e se beneficiar dele sem corrigir o código do cliente em todos os lugares . Existem outros benefícios em não prometer mais do que o necessário, mas este sozinho é tão grande que, na prática, nenhum outro é necessário.

Kilian Foth
fonte
12
Exposing the concrete type Array usually creates many additional promises, e.g. that you can sort the collection by a function of your own choosing...Brilhante. Eu simplesmente não pensei nisso. Sim, ele traz de volta uma 'iteração' e apenas uma iteração!
MyDaftQuestions
18

Ocultar a implementação é um princípio central do OOP e uma boa idéia em todos os paradigmas, mas é especialmente importante para os iteradores (ou como eles são chamados nessa linguagem específica) em idiomas que suportam iteração lenta.

O problema de expor o tipo concreto de iteráveis ​​- ou mesmo interfaces semelhantes IList<T>- não está nos objetos que os expõem, mas nos métodos que os utilizam. Por exemplo, digamos que você tenha uma função para imprimir uma lista de Foos:

void PrintFoos(IList<Foo> foos)
{
    foreach (foo in foos)
    {
        Console.WriteLine(foo);
    }
}

Você só pode usar essa função para imprimir listas de foo - mas somente se elas implementarem IList<Foo>

IList<Foo> foos = //.....
PrintFoos(foos);

Mas e se você quiser imprimir todos os itens indexados da lista? Você precisará criar uma nova lista:

IList<Foo> everySecondFoo = new List<T>();
bool isIndexEven = true;
foreach (foo; foos)
{
    if (isIndexEven)
    {
        everySecondFoo.Add(foo);
    }
    isIndexEven = !isIndexEven;
}
PrintFoos(everySecondFoo);

Isso é bastante longo, mas com o LINQ podemos fazer isso com uma linha, que é realmente mais legível:

PrintFoos(foos.Where((foo, i) => i % 2 == 0).ToList());

Agora, você percebeu .ToList()o final? Isso converte a consulta lenta em uma lista, para que possamos transmiti-la PrintFoos. Isso requer a alocação de uma segunda lista e duas passagens nos itens (um na primeira lista para criar a segunda lista e outro na segunda lista para imprimi-la). Além disso, e se tivermos isso:

void Print6Foos(IList<Foo> foos)
{
    int counter = 0;
    foreach (foo in foos)
    {
        Console.WriteLine(foo);
        ++ counter;
        if (6 < counter)
        {
            return;
        }
    }
}

// ........

Print6Foos(foos.Where((foo, i) => i % 2 == 0).ToList());

E se foostiver milhares de entradas? Teremos que passar por todos eles e alocar uma lista enorme apenas para imprimir 6 deles!

Digite Enumerators - a versão em C # de The Iterator Pattern. Em vez de nossa função aceitar uma lista, nós a aceitamos Enumerable:

void Print6Foos(Enumerable<Foo> foos)
{
    // everything else stays the same
}

// ........

Print6Foos(foos.Where((foo, i) => i % 2 == 0));

Agora Print6Foospodemos iterar preguiçosamente os 6 primeiros itens da lista, e não precisamos tocar no restante.

Não expor a representação interna é a chave aqui. Quando Print6Foosaceitamos uma lista, tivemos que dar uma lista - algo que suporta acesso aleatório - e, portanto, tivemos que alocar uma lista, porque a assinatura não garante que apenas iterará. Ocultando a implementação, podemos criar facilmente um Enumerableobjeto mais eficiente que suporta apenas o que a função realmente precisa.

Idan Arye
fonte
6

Expor a representação interna praticamente nunca é uma boa ideia. Ele não apenas dificulta o raciocínio, mas também a manutenção. Imagine que você escolheu qualquer representação interna - digamos uma IList<T>- e a expôs. Qualquer pessoa que use seu código pode acessar a Lista e o código pode confiar na representação interna de uma Lista.

Por qualquer motivo, você está decidindo alterar a representação interna para IAmWhatever<T>um momento posterior. Em vez disso, basta alterar as partes internas da sua classe, você terá que alterar todas as linhas de código e método, dependendo da representação interna do tipo IList<T>. Isso é complicado e propenso a erros, quando você é o único a usar a classe, mas pode quebrar o código de outras pessoas usando a sua classe. Se você acabou de expor um conjunto de métodos públicos sem expor os internos, poderá ter alterado a representação interna sem que nenhuma linha de código fora da sua classe seja notada, trabalhando como se nada tivesse mudado.

É por isso que o encapsulamento é um dos aspectos mais importantes de qualquer projeto de software não trivial.

Paul Kertscher
fonte
6

Quanto menos você diz, menos você tem que continuar dizendo.

Quanto menos você perguntar, menos precisará ser informado.

Se o seu código apenas for exposto IEnumerable<T>, o que é compatível apenas com GetEnumrator()ele pode ser substituído por qualquer outro código que seja compatível IEnumerable<T>. Isso adiciona flexibilidade.

Se o seu código usar apenas, IEnumerable<T>ele poderá ser suportado por qualquer código implementado IEnumerable<T>. Novamente, há flexibilidade extra.

Todo o linq-to-objects, por exemplo, depende apenas de IEnumerable<T>. Enquanto faz o caminho rápido, algumas implementações específicas IEnumerable<T>fazem isso de uma forma de teste e uso que pode sempre recair apenas no uso GetEnumerator()e na IEnumerator<T>implementação que retorna. Isso oferece muito mais flexibilidade do que se tivesse sido construído sobre matrizes ou listas.

Jon Hanna
fonte
Boa resposta clara. Muito apto a mantê-lo breve e, portanto, siga seu conselho na primeira frase. Bravo!
Daniel Hollinrake
0

Uma frase que eu gosto de usar é o comentário do @CaptainMan: "É bom para o consumidor conhecer detalhes internos. É ruim para o cliente precisar saber detalhes internos".

Se um cliente precisar digitar arrayou IList<T>em seu código, quando esses tipos forem detalhes internos, o consumidor deverá acertá-los. Se estiverem errados, o consumidor pode não compilar ou até obter resultados errados. Isso gera os mesmos comportamentos que as outras respostas de "sempre oculte os detalhes da implementação porque você não deve expô-los", mas começa a explorar as vantagens de não expor. Se você nunca expõe um detalhe de implementação, seu cliente nunca pode se comprometer usando-o!

Eu gosto de pensar nisso dessa maneira, porque abre o conceito de ocultar a implementação em tons de significado, em vez de um draconiano "sempre oculte os detalhes da sua implementação". Existem muitas situações em que expor a implementação é totalmente válido. Considere o caso dos drivers de dispositivo em tempo real, em que a capacidade de pular camadas de abstração e colocar bytes nos lugares certos pode ser a diferença entre definir o tempo ou não. Ou considere um ambiente de desenvolvimento em que você não tenha tempo para criar "boas APIs" e precise usar as coisas imediatamente. Muitas vezes, a exposição de APIs subjacentes nesses ambientes pode ser a diferença entre cumprir um prazo ou não.

No entanto, à medida que você deixa esses casos especiais para trás e analisa os casos mais gerais, torna-se cada vez mais importante ocultar a implementação. Expor detalhes da implementação é como uma corda. Quando usado corretamente, você pode fazer todo tipo de coisa, como escalar montanhas altas. Usado incorretamente, e pode amarrá-lo em um laço. Quanto menos você souber como seu produto será usado, mais cuidadoso você precisará ter.

O estudo de caso que vejo repetidamente é aquele em que um desenvolvedor faz uma API que não é muito cuidadosa ao ocultar os detalhes da implementação. Um segundo desenvolvedor, usando essa biblioteca, descobre que a API está faltando alguns dos principais recursos que eles gostariam. Em vez de escrever uma solicitação de recurso (que pode ter um tempo de resposta de meses), eles decidem abusar do acesso aos detalhes ocultos da implementação (que leva segundos). Isso não é ruim por si só, mas muitas vezes o desenvolvedor da API nunca planejou fazer parte da API. Mais tarde, quando eles aprimoram a API e alteram os detalhes subjacentes, descobrem que estão acorrentados à implementação antiga, porque alguém a usou como parte da API. A pior versão disso é que alguém usou sua API para fornecer um recurso centrado no cliente e agora sua empresa ' O salário está vinculado a essa API defeituosa! Simplesmente, expondo os detalhes da implementação quando você não sabia o suficiente sobre seus clientes, provou ser corda suficiente para amarrar um nó na sua API!

Cort Ammon - Restabelecer Monica
fonte
11
Re: "Ou considere um ambiente de desenvolvimento em que você não tenha tempo para criar 'boas APIs' e precise usar as coisas imediatamente": se você se encontra nesse ambiente e, por algum motivo, não deseja sair ( ou não tem certeza), recomendo o livro Março da Morte: O Guia Completo do Desenvolvedor de Software para Sobreviver a Projetos Impossíveis de Missão . Tem excelentes conselhos sobre o assunto. (Além disso, eu recomendo mudar sua mente sobre não desistir.)
ruach
@ruakh Descobri que é sempre importante entender como trabalhar em um ambiente em que você não tem tempo para criar boas APIs. Francamente, por mais que gostemos da abordagem da torre de marfim, o código real é o que realmente paga as contas. Quanto mais cedo admitirmos isso, mais cedo poderemos começar a abordar o software como um equilíbrio, equilibrando a qualidade da API com o código produtivo, para corresponder ao nosso ambiente exato. Eu já vi muitos jovens desenvolvedores presos em uma confusão de ideais, sem perceber que precisam flexibilizá-los. (Eu também estive tão jovem programador .. ainda se recuperando de 5 anos de "boa API" design)
Cort Ammon - Reintegrar Monica
11
Código real paga as contas, sim. Um bom design de API é como você garante que continuará sendo capaz de gerar código real. O código de baixa qualidade deixa de se pagar quando um projeto se torna impossível de manter e torna-se impossível corrigir bugs sem criar novos. (E de qualquer maneira, este é um falso dilema Que bom código requer não é. Tempo tanto como backbone .)
ruach
11
@ruakh O que eu descobri é que é possível criar um bom código sem "boas APIs". Se for dada a oportunidade, boas APIs são boas, mas tratá-las como uma necessidade causa mais conflitos do que vale a pena. Considere o seguinte: a API para corpos humanos é horrendo, mas ainda acho que eles são muito bons pequenos feitos de engenharia =)
Cort Ammon - Reintegrar Monica
O outro exemplo que eu daria é o que foi a inspiração para o argumento sobre a criação de tempo. Um dos grupos com quem trabalho tinha algumas coisas de driver de dispositivo que eles precisavam desenvolver, com requisitos rígidos em tempo real. Eles tinham 2 APIs para trabalhar. Um era bom, um era menos. A boa API não conseguiu marcar o tempo porque ocultou a implementação. A API menor expôs a implementação e, ao fazer isso, permite usar técnicas específicas do processador para criar um produto funcional. A boa API foi educadamente colocada na prateleira e eles continuaram a atender aos requisitos de tempo.
Cort Ammon - Restabelece Monica
0

Você não sabe necessariamente qual é a representação interna dos seus dados - ou pode se tornar no futuro.

Suponha que você tenha uma fonte de dados que você representa como uma matriz, você pode iterar sobre ela com um loop for regular.

Agora diga que você deseja refatorar. Seu chefe pediu que a fonte de dados fosse um objeto de banco de dados. Além disso, ele gostaria de ter a opção de extrair dados de uma API, um mapa de hash, uma lista vinculada, um tambor magnético giratório, um microfone ou uma contagem em tempo real de aparas de iaque encontradas na Mongólia Exterior.

Bem, se você tiver apenas um loop for, poderá modificar seu código facilmente para acessar o objeto de dados da maneira que ele espera ser acessado.

Se você tem 1000 classes tentando acessar o objeto de dados, agora você tem um grande problema de refatoração.

Um iterador é uma maneira padrão de acessar uma fonte de dados baseada em lista. É uma interface comum. Usá-lo tornará sua fonte de dados de código independente.

superluminário
fonte