Princípio aberto-fechado

Olá Habr! Aqui está a tradução de um artigo de Robert Martin, do Princípio Aberto-Fechado , que ele publicou em janeiro de 1996. O artigo, para dizer o mínimo, não é o mais recente. Mas no RuNet, os artigos do tio Bob sobre o SOLID são recontados apenas de forma truncada, então pensei que uma tradução completa não seria supérflua.



Decidi começar com a letra O, já que o princípio de abertura-fechamento é de fato central. Entre outras coisas, há muitas sutilezas importantes que merecem atenção:


  • Nenhum programa pode ser "fechado" 100%.
  • A programação orientada a objetos (OOP) opera não com objetos físicos do mundo real, mas com conceitos - por exemplo, o conceito de "ordenação".

Este é o primeiro artigo da minha coluna Engineer Notes para The C ++ Report . Os artigos publicados nesta coluna se concentrarão no uso de C ++ e OOP e abordarão as dificuldades no desenvolvimento de software. Vou tentar tornar os materiais pragmáticos e úteis para a prática de engenheiros. Para a documentação do design orientado a objetos nestes artigos, utilizarei a notação de Buch.


Existem muitas heurísticas associadas à programação orientada a objetos. Por exemplo, “todas as variáveis ​​de membro devem ser privadas” ou “variáveis ​​globais devem ser evitadas” ou “a determinação de tipo em tempo de execução é perigosa”. Qual o motivo de tais heurísticas? Por que eles são verdadeiros? Eles sempre são verdadeiros? Esta coluna explora o princípio de design subjacente a essas heurísticas - o princípio de abertura-fechamento.
Ivar Jacobson disse: “Todos os sistemas mudam durante o ciclo de vida. Isso deve ser lembrado ao projetar um sistema com mais de uma versão esperada. ” Como podemos projetar um sistema para que ele seja estável diante das mudanças e que tenha mais de uma versão esperada? Bertrand Meyer nos contou sobre isso em 1988, quando o agora famoso princípio da abertura-proximidade foi formulado:


As entidades do programa (classes, módulos, funções, etc.) devem estar abertas para expansão e fechadas para alterações.


Se uma alteração no programa envolve uma cascata de alterações nos módulos dependentes, sinais indesejáveis ​​de um design "ruim" aparecem no programa.


O programa se torna frágil, inflexível, imprevisível e sem uso. O princípio da abertura-proximidade resolve esses problemas de maneira muito direta. Ele diz que é necessário projetar módulos que nunca mudam . Quando os requisitos mudam, você precisa expandir o comportamento de tais módulos adicionando novo código, em vez de alterar o código antigo que já está funcionando.


Descrição do produto


Módulos que atendem ao princípio de abertura-proximidade têm duas características principais:


  1. Aberto para expansão. Isso significa que o comportamento do módulo pode ser expandido. Ou seja, podemos adicionar um novo comportamento ao módulo de acordo com os requisitos variáveis ​​do aplicativo ou para atender às necessidades de novos aplicativos.
  2. Fechado para mudança. O código fonte desse módulo é intocável. Ninguém tem o direito de fazer alterações.

Parece que esses dois sinais não se encaixam. A maneira padrão de estender o comportamento de um módulo é fazer alterações nele. Um módulo que não pode ser alterado geralmente é pensado como um módulo com comportamento fixo. Como essas duas condições opostas podem ser cumpridas?


A chave da solução é a abstração.


No C ++, usando os princípios do design orientado a objetos, é possível criar abstrações fixas que podem representar um conjunto ilimitado de comportamentos possíveis.


Abstrações são classes básicas abstratas e um conjunto ilimitado de comportamentos possíveis é representado por todas as classes possíveis possíveis. Um módulo pode manipular a abstração. Esse módulo está fechado para alterações, pois depende de uma abstração fixa. Além disso, o comportamento do módulo pode ser expandido criando novos descendentes da abstração.


O diagrama abaixo mostra uma opção de design simples que não atende ao princípio de abertura-proximidade. Ambas as classes, Client e Server , não são abstratas. Não há garantia de que as funções que são membros da classe Server sejam virtuais. A classe Client usa a classe Server . Se queremos que o objeto da classe Client use um objeto de servidor diferente, devemos alterar a classe Client para se referir à nova classe de servidor.


imagem
Cliente fechado


E o diagrama a seguir mostra a opção de design correspondente, que atende ao princípio de abertura-proximidade. Nesse caso, a classe AbstractServer é uma classe abstrata, cujas funções de membro são virtuais. A classe Client usa abstração. No entanto, os objetos da classe Client usarão objetos da classe sucessora do Server . Se quisermos que objetos da classe Client usem uma classe de servidor diferente, apresentaremos um novo descendente da classe AbstractServer . A classe Client permanecerá inalterada.


imagem
Cliente aberto


Resumo da Shape


Considere um aplicativo que deve desenhar círculos e quadrados em uma GUI padrão. Círculos e quadrados devem ser desenhados em uma ordem específica. Na ordem correspondente, uma lista de círculos e quadrados será compilada, o programa deve passar por essa lista na ordem e desenhar cada círculo ou quadrado.


Em C, usando técnicas de programação procedural que não atendem ao princípio de abertura / fechamento, podemos resolver esse problema, como mostra a Listagem 1. Aqui vemos muitas estruturas de dados com o mesmo primeiro elemento. Este elemento é um código de tipo que identifica a estrutura de dados como um círculo ou quadrado. A função DrawAllShapes passa por uma matriz de ponteiros para essas estruturas de dados, reconhecendo o código de tipo e chamando a função correspondente ( DrawCircle ou DrawSquare ).


 // 1 //  /    enum ShapeType {circle, square} struct Shape { ShapeType itsType; }; struct Circle { ShapeType itsType; double itsRadius; Point itsCenter; }; struct Square { ShapeType itsType; double itsSide; Point itsTopLeft; }; // //     // void DrawSquare(struct Square*) void DrawCircle(struct Circle*); typedef struct Shape *ShapePointer; void DrawAllShapes(ShapePointer list[], int n) { int i; for (i=0; i<n; i++) { struct Shape* s = list[i]; switch (s->itsType) { case square: DrawSquare((struct Square*)s); break; case circle: DrawCircle((struct Circle*)s); break; } } } 

A função DrawAllShapes não atende ao princípio do fechamento de abertura, porque não pode ser "fechado" a partir de novos tipos de formas. Se eu quisesse expandir essa função com a capacidade de desenhar formas de uma lista que inclui triângulos, precisaria alterar a função. Na verdade, preciso alterar a função para cada novo tipo de forma que preciso desenhar.


Obviamente, este programa é apenas um exemplo. Na vida real, o operador do DrawAllShapes função DrawAllShapes seria repetido várias vezes em várias funções em todo o aplicativo e cada uma faria algo diferente. Adicionar novas formas a esse aplicativo significa encontrar todos os locais onde essas switch (ou if/else cadeia) são usadas e adicionar uma nova forma a cada uma delas. Além disso, é muito improvável que todas as switch e cadeias if/else sejam tão bem estruturadas quanto no DrawAllShapes . É muito mais provável que predicados em if sejam combinadas com operadores lógicos ou blocos de casos de switch de switch sejam combinados de forma a "simplificar" um local específico no código. Portanto, o problema de encontrar e entender todos os locais em que você precisa adicionar uma nova figura pode não ser trivial.


Na Listagem 2, mostrarei o código que demonstra uma solução quadrada / circular que atende ao princípio do fechamento de abertura. Uma classe Shape abstrata é introduzida. Esta classe abstrata contém uma função Draw pura e virtual. As classes Circle e Square são descendentes da classe Shape .


 // 2 //  /  - class Shape { public: virtual void Draw() const = 0; }; class Square : public Shape { public: virtual void Draw() const; }; class Circle : public Shape { public: virtual void Draw() const; }; void DrawAllShapes(Set<Shape*>& list) { for (Iterator<Shape*>i(list); i; i++) (*i)->Draw(); } 

Observe que, se quisermos estender o comportamento da função DrawAllShapes na Listagem 2 para desenhar um novo tipo de forma, tudo o que precisamos fazer é adicionar um novo descendente da classe Shape . Não há necessidade de alterar a função DrawAllShapes . Portanto, DrawAllShapes atende ao princípio de abertura-proximidade. Seu comportamento pode ser expandido sem alterar a própria função.


No mundo real, a classe Shape conteria muitos outros métodos. Ainda assim, adicionar uma nova forma ao aplicativo ainda é muito simples, pois tudo que você precisa fazer é inserir um novo herdeiro e implementar essas funções. Não há necessidade de vasculhar todo o aplicativo em busca de locais que exigem mudanças.


Portanto, os programas que atendem ao princípio da abertura-proximidade são alterados adicionando novo código, e não alterando o existente; eles não transformam em cascata as alterações características dos programas que não correspondem a esse princípio.


Estratégia de entrada fechada


Obviamente, nenhum programa pode ser 100% fechado. Por exemplo, o que acontece com a função DrawAllShapes na Listagem 2 se decidirmos que círculos e quadrados devem ser desenhados primeiro? A função DrawAllShapes não DrawAllShapes fechada para esse tipo de alteração. Em geral, não importa o quão "fechado" o módulo esteja, sempre há algum tipo de alteração da qual ele não está fechado.


Como o fechamento não pode ser completo, ele deve ser introduzido estrategicamente. Ou seja, o designer deve escolher os tipos de alterações das quais o programa será fechado. Isso requer alguma experiência. Um desenvolvedor experiente conhece os usuários e o setor suficientemente bem para calcular a probabilidade de várias alterações. Ele então garante que o princípio da abertura-proximidade seja respeitado nas mudanças mais prováveis.


Uso de abstração para obter proximidade adicional


Como podemos fechar a função DrawAllShapes de alterações na ordem do desenho? Lembre-se de que o fechamento é baseado na abstração. Portanto, para fechar DrawAllShapes do pedido, precisamos de algum tipo de "abstração do pedido". Um caso especial de pedido, apresentado acima, é desenhar figuras de um tipo na frente de figuras de outro tipo.


A política de pedidos implica que, com dois objetos, você pode determinar qual deles deve ser desenhado primeiro. Portanto, podemos definir um método para a classe Shape chamada Precedes , que pega outro objeto Shape como argumento e retorna um valor booleano true se o objeto Shape que recebeu essa mensagem precisar ser classificado antes do objeto Shape que foi passado como argumento.


No C ++, essa função pode ser representada como uma sobrecarga do operador "<". A Listagem 3 mostra a classe Shape com métodos de classificação.


Agora que temos uma maneira de determinar a ordem dos objetos da classe Shape , podemos classificá-los e desenhá-los. A Listagem 4 mostra o código C ++ correspondente. Ele usa as classes Set , OrderedSet e Iterator da categoria Components desenvolvida em meu livro (Designing Applications C ++ Orientados a Objetos usando o Método Booch, Robert C. Martin, Prentice Hall, 1995).


Portanto, implementamos a ordem dos objetos da classe Shape e os desenhamos na ordem apropriada. Mas ainda não temos uma implementação da abstração do pedido. Obviamente, todo objeto Shape deve substituir o método Precedes para determinar a ordem. Como isso pode funcionar? Que código precisa ser escrito em Circle::Precedes para que os círculos sejam desenhados em quadrados? Preste atenção na lista 5.


 // 3 //  Shape    . class Shape { public: virtual void Draw() const = 0; virtual bool Precedes(const Shape&) const = 0; bool operator<(const Shape& s) {return Precedes(s);} }; 

 // 4 // DrawAllShapes   void DrawAllShapes(Set<Shape*>& list) { //    OrderedSet  . OrderedSet<Shape*> orderedList = list; orderedList.Sort(); for (Iterator<Shape*> i(orderedList); i; i++) (*i)->Draw(); } 

 // 5 //    bool Circle::Precedes(const Shape& s) const { if (dynamic_cast<Square*>(s)) return true; else return false; } 

É claro que essa função não atende ao princípio da abertura-proximidade. Não há como fechá-lo dos novos descendentes da classe Shape . Cada vez que um novo descendente da classe Shape aparece, essa função precisa ser alterada.


Usando uma abordagem orientada a dados para obter o fechamento


A proximidade dos herdeiros da classe Shape pode ser obtida usando uma abordagem tabular que não provoca alterações em cada classe herdada. Um exemplo dessa abordagem é mostrado na Listagem 6.


Usando essa abordagem, fechamos com êxito a função DrawAllShapes das alterações relacionadas à ordem e a cada descendente da classe Shape - da introdução de um novo descendente ou de uma alteração na política de ordenação de objetos da classe Shape dependendo do tipo (por exemplo, objetos da classe Squares ser desenhado primeiro).


 // 6 //     #include <typeinfo.h> #include <string.h> enum {false, true}; typedef int bool; class Shape { public: virtual void Draw() const = 0; virtual bool Precedes(const Shape&) const; bool operator<(const Shape& s) const {return Precedes(s);} private: static char* typeOrderTable[]; }; char* Shape::typeOrderTable[] = { "Circle", "Square", 0 }; //      . //   ,    //  . ,    , //      bool Shape::Precedes(const Shape& s) const { const char* thisType = typeid(*this).name(); const char* argType = typeid(s).name(); bool done = false; int thisOrd = -1; int argOrd = -1; for (int i=0; !done; i++) { const char* tableEntry = typeOrderTable[i]; if (tableEntry != 0) { if (strcmp(tableEntry, thisType) == 0) thisOrd = i; if (strcmp(tableEntry, argType) == 0) argOrd = i; if ((argOrd > 0) && (thisOrd > 0)) done = true; } else // table entry == 0 done = true; } return thisOrd < argOrd; } 

O único elemento que não está impedido de alterar a ordem das formas de desenho é uma tabela. A tabela pode ser colocada em um módulo separado, separado de todos os outros módulos e, portanto, suas alterações não afetarão outros módulos.


Fechamento adicional


Este não é o fim da história. Fechamos a hierarquia da classe Shape e a função DrawAllShapes de alterar a política de pedidos com base no tipo de formas. No entanto, os descendentes da classe Shape não estão fechados das políticas de pedidos que não estão associadas aos tipos de Shape . Parece que precisamos organizar o desenho das formas de acordo com uma estrutura de nível superior. Um estudo completo desses problemas está além do escopo deste artigo; no entanto, um leitor interessado pode pensar em como resolver esse problema usando a classe abstrata OrderedObject contida na classe OrderedShape , que herda das OrderedObject Shape e OrderedObject .


Heurísticas e Convenções


Como já mencionado no início do artigo, o princípio de abertura-proximidade é a principal motivação por trás de muitas heurísticas e convenções que surgiram ao longo dos muitos anos de desenvolvimento do paradigma OOP. A seguir, são os mais importantes.


Tornar todas as variáveis ​​de membro privadas


Esta é uma das convenções mais duradouras da OLP. As variáveis ​​de membro devem ser conhecidas apenas pelos métodos da classe em que estão definidas. Membros variáveis ​​não devem ser conhecidos por nenhuma outra classe, incluindo classes derivadas. Portanto, eles devem ser declarados com um modificador de acesso private , não public ou protected .
À luz do princípio da abertura-proximidade, a razão de tal convenção é compreensível. Quando as variáveis ​​dos membros da classe mudam, cada função que depende delas deve mudar. Ou seja, a função não está fechada de alterações nessas variáveis.


Em OOP, esperamos que os métodos de uma classe não sejam fechados para alterações nas variáveis ​​que são membros dessa classe. No entanto, esperamos que qualquer outra classe, incluindo subclasses, seja fechada de alterações nessas variáveis. Isso é chamado de encapsulamento.


Mas e se você tiver uma variável sobre a qual tenha certeza de que ela nunca mudará? Faz sentido torná-lo private ? Por exemplo, a Listagem 7 mostra a classe Device que contém o bool status membro variável. Ele armazena o status da última operação. Se a operação foi bem-sucedida, o valor da variável status será true , caso contrário, false .


 // 7 //   class Device { public: bool status; }; 

Sabemos que o tipo ou significado dessa variável nunca será alterado. Então, por que não torná-lo public e dar ao cliente acesso direto a ele? Se a variável realmente nunca mudar, se todos os clientes seguirem as regras e apenas lerem essa variável, não haverá nada de errado com o fato de a variável ser pública. No entanto, considere o que acontecerá se um dos clientes aproveitar a oportunidade para gravar nessa variável e alterar seu valor.


De repente, esse cliente pode afetar a operação de qualquer outro cliente da classe Device . Isso significa que é impossível fechar os clientes da classe Device das alterações neste módulo incorreto. Isso é muito risco.


Por outro lado, suponha que tenhamos a classe Time , mostrada na Listagem 8. Qual é o perigo de publicidade das variáveis ​​que são membros dessa classe? É muito improvável que eles mudem. Além disso, não importa se os módulos do cliente alteram os valores dessas variáveis ​​ou não, pois é assumida uma alteração nessas variáveis. Também é muito improvável que classes herdadas possam depender do valor de uma variável de membro específica. Então, há algum problema?


 // 8 class Time { public: int hours, minutes, seconds; Time& operator-=(int seconds); Time& operator+=(int seconds); bool operator< (const Time&); bool operator> (const Time&); bool operator==(const Time&); bool operator!=(const Time&); }; 

A única reclamação que eu poderia apresentar ao código da Listagem 8 é que a mudança de horário não é atômica. Ou seja, o cliente pode alterar o valor da variável de minutes sem alterar o valor da variável de hours . Isso pode fazer com que um objeto da classe Time contenha dados inconsistentes. Eu preferiria introduzir uma única função para definir a hora, que levaria três argumentos, o que tornaria a configuração da hora uma operação atômica. Mas este é um argumento fraco.


É fácil criar outras condições sob as quais a publicidade dessas variáveis ​​possa levar a problemas. Em última análise, no entanto, não há motivos convincentes para torná-los private . Eu ainda acho que tornar essas variáveis ​​públicas é um estilo ruim, mas talvez não seja um design ruim. Eu acredito que esse é um estilo ruim, porque não custa quase nada entrar nas funções apropriadas para acessar esses membros, e definitivamente vale a pena se proteger do pequeno risco associado à possível ocorrência de problemas com o fechamento.


Portanto, em casos tão raros, quando o princípio de abertura-fechamento não é violado, a proibição de variáveis public - e protected depende mais do estilo e não do conteúdo.


Nenhuma variável global ... de jeito nenhum!


O argumento contra variáveis ​​globais é o mesmo que o argumento contra variáveis ​​membro públicas. Nenhum módulo que depende de uma variável global pode ser fechado a partir de um módulo que possa gravar nela. Qualquer módulo que use essa variável de uma maneira não pretendida por outros módulos quebrará esses módulos. É muito arriscado ter muitos módulos, dependendo dos caprichos de um único módulo malicioso.
Por outro lado, nos casos em que variáveis ​​globais têm um pequeno número de módulos dependentes delas ou não podem ser usadas da maneira errada, elas não causam danos. O designer deve avaliar quanta privacidade é sacrificada e determinar se a conveniência fornecida pela variável global vale a pena.


Aqui, novamente, problemas de estilo entram em cena. Alternativas ao uso de variáveis ​​globais geralmente são baratas. Nesses casos, o uso de uma técnica que introduz, embora pequena, mas um risco de fechamento, em vez de uma técnica que elimina completamente esse risco, é um sinal de mau estilo. No entanto, às vezes o uso de variáveis ​​globais é realmente conveniente. Um exemplo típico são as variáveis ​​globais cout e cin. Nesses casos, se o princípio de abertura-proximidade não for violado, você poderá sacrificar o estilo por conveniência.


RTTI é perigoso


Outra proibição comum é o uso de dynamic_cast . Freqüentemente, dynamic_cast ou alguma outra forma de determinação do tipo de tempo de execução (RTTI) é acusada de ser uma técnica extremamente perigosa e, portanto, deve ser evitada. Ao mesmo tempo, eles geralmente dão um exemplo da Lista 9, que obviamente viola o princípio de abertura-proximidade. No entanto, a Listagem 10 mostra um exemplo de programa semelhante que usa dynamic_cast sem violar o princípio de abertura e fechamento.


A diferença entre eles é que, no primeiro caso, mostrado na Listagem 9, o código precisa ser alterado toda vez que um novo descendente da classe Shape aparece (sem mencionar que essa é uma solução absolutamente ridícula). No entanto, na Listagem 10, nenhuma mudança é necessária neste caso. Portanto, o código na Listagem 10 não viola o princípio de abertura e fechamento.
Nesse caso, a regra geral é que o RTTI pode ser usado se o princípio do fechamento da abertura não for violado.


 // 9 //RTTI,   -. class Shape {}; class Square : public Shape { private: Point itsTopLeft; double itsSide; friend DrawSquare(Square*); }; class Circle : public Shape { private: Point itsCenter; double itsRadius; friend DrawCircle(Circle*); }; void DrawAllShapes(Set<Shape*>& ss) { for (Iterator<Shape*>i(ss); i; i++) { Circle* c = dynamic_cast<Circle*>(*i); Square* s = dynamic_cast<Square*>(*i); if (c) DrawCircle(c); else if (s) DrawSquare(s); } } 

 // 10 //RTTI,    -. class Shape { public: virtual void Draw() cont = 0; }; class Square : public Shape { // . }; void DrawSquaresOnly(Set<Shape*>& ss) { for (Iterator<Shape*>i(ss); i; i++) { Square* s = dynamic_cast<Square*>(*i); if (s) s->Draw(); } } 

Conclusão


Eu poderia falar por um longo tempo sobre o princípio da abertura-proximidade. De muitas maneiras, esse princípio é mais importante para a programação orientada a objetos. A conformidade com esse princípio específico fornece as principais vantagens da tecnologia orientada a objetos, como a reutilização e o suporte.


, - -. , , , , , .

Source: https://habr.com/ru/post/pt472186/


All Articles