Composition over inheritance, prefer you must
Yoda?!
Sistemas de software estão cada vez mais complexos. A quantidade de fatores que podem levar a comportamentos defeituosos é crescente e esses fatores vão muito além do código. É fato que mesmo sistemas com código bem-escrito, eventualmente, apresentam problemas.  Entretanto, empiricamente é possível estabelecer correlação entre a qualidade do código e a frequência de bugs.
0
Considerações?x

Falhas, erros e defeitos

Pequenas falhas (faults) – como bugs ou validações insuficientes em “entradas” fornecidas por usuários – deixam o sistema em estado inconsistente, o que, ocasionalmente, dá origem a erros (errors), ou seja, comportamentos indesejados do software que, eventualmente, culminam em defeitos (failures), geralmente indisponibilidade

As leis de Lehman apontam que, para continuarem relevantes, sistemas devem, continuamente, receber adaptações. Além disso, na medida que essas adaptações são realizadas, se nada for feito para mitigação, há sempre aumento da complexidade. Parece, também, haver correlação entre complexidade dos sistemas e o custo/risco para fazer adaptações.
0
Considerações?x
Se não houver um esforço consciente para a mitigação da complexidade, bons sistemas ficam cada vez mais caros de manter ao longo do tempo, até se tornarem insustentáveis.

Um dos argumentos a favor do paradigma orientado a objetos é a escrita de códigos menos complexos. Entretanto, para que esse argumento seja válido, programadores precisam do entendimento que tão importante quanto escrever “código que funciona”, é escrever “código fácil de manter e evoluir” e, em consequência disso, combatendo a complexidade, o que diminui a frequência de bugs.

Suposta origem do termo 'bug' n. 1

O uso do termo bug para descrever defeitos inexplicáveis foi parte do jargão da engenharia por várias décadas; pode originalmente ter sido usado na engenharia mecânica para descrever maus funcionamentos mecânicos. Diz-se que o termo foi criado por Thomas Edison quando um inseto causou problemas de leitura em seu fonógrafo em 1878, mas pode ser que o termo seja mais antigo.

Estudos apontam que há correlação entre a quantidade de modificações realizadas em um trecho código e a quantidade de bugs observados. Quanto mais se mexe em um trecho de código, maiores as chances de que ele de problema. Quanto mais pessoas mexendo em um trecho de código, também. Por isso, idealmente,  novas features deveriam criadas somente com código novo. Esta foi, aliás, a proposta de Bertrand Meyer, importante cientista de computação, em 1980, quando ele indicou que bons códigos deveriam ser, em princípio, aberto/fechado (OCP).

Artefatos de software (classes, módulos, funções, etc.) deveriam estar abertos para extensão, mas fechados para modificação.

OO facilita a observância de OCP por facilitar a elaboração de designs com pontos de extensão.

Suposta origem do termo 'bug' n. 2

A invenção do termo frequentemente é atribuída a Grace Hopper, ao publicar em 1947 que a causa do mau funcionamento no computador Mark II, da Universidade de Harvard, seria um inseto preso nos contatos de um relê.

Um ponto de extensão constitui um alternativa para adição de funcionalidades apenas pela adição de código novo. Hipoteticamente, sem mexer no que está funcionando.

Suposta origem do termo 'bug' n. 3

Electronic Numerical Integrator and Computer (ENIAC), primeiro computador digital completamente eletrônico, também contribuiu ao uso da palavra. Ele era movido a válvulas e, assim, atraía milhares de insetos. Como de dezenas a centenas de válvulas queimavam a cada hora, o computador, que ocupava o espaço de uma sala, era aberto frequentemente, e montes de insetos mortos eram varridos para fora. Diz-se que esses insetos provocavam curtos-circuitos nas placas do ENIAC, levando a falhas nos programas.

Bons designs orientados a objetos respeitam OCP, prevendo evolução sempre através da exploração de “pontos de extensão”.

Usando herança como estratégia de extensão

Em um código orientado a objetos, toda classe é um ponto de extensão, exceto quando indicado explicitamente o contrário. Afinal, toda classe pode ser estendida usando herança.

Considere a necessidade de implementar uma integração para uma máquina, presente na planta produtiva. Essa máquina é importante e a interface da classe que realizará a integração será bem específica.

public class MyMachine
{
  public void Initialize()
  {
    // initialization logic
  }

  /*  behavior */
  
  private void ExecuteCommand(string command)
  {
    // ..
  }
}

O método ExecuteCommand, no exemplo, é privado por executar operações de baixo nível e, durante o design, entendeu-se que não há razões para que ele seja acessível externamente, ficando restrito apenas a implementação dos demais comportamentos.

Considere agora que uma nova máquina, do mesmo fornecedor, é adquirida. Além disso, assuma que também há necessidade de desenvolver integração para ela. Ao verificar os manuais se constata que a máquina nova opera de maneira quase idêntica a anterior, porém, com suporte a algumas operações adicionais.

Uma primeira abordagem para implementar suporte a nova máquina, ingênua, poderia ser como indicado no código que segue:

public class MyMachine
{
  public void Initialize()
  {
    // initialization logic
  }
  /* common behavior */
  
  protected virtual void ExecuteCommand(string command)
  {
    // ..
  }
}

public class MyNewMachine : MyMachine
{
  public override void Initialize()
  {
    // new initialization logic.
  }
  /* any new behavior */
  
  protected override void ExecuteCommand(string command)
  {
    // ..
    base.ExecuteCommand(command);
  }
}

Conceitualmente, poderíamos dizer que, nessa modelagem, a classe que irá suportar a máquina nova deve “herdar” todas as características da anterior, adicionando novos comportamentos.

Sobrescrita de comportamentos como estratégia de extensão

No código de exemplo, o método ExecuteCommand é marcado como protected ficando visível apenas para a classe que o define e para as classes derivadas que o sobrescrevem, opcionalmente. Repare também que, na classe derivada, tomamos o cuidado de invocar o método da classe base antes da conclusão da execução.

O problema potencial dessa estratégia é que a nova implementação de ExecuteCommand pode não ser compatível com a “máquina anterior”, violando uma ideia importante: o princípio de substituição de Liskov (LSP)

Objetos de uma superclasse devem poder ser substituídos por objetos de uma suas subclasses sem quebrar a aplicação.

Outra forma de “resolver” ExecuteCommand seria controlar seu momento de execução na classe base, mantendo-a fechada para modificações, mas aberta para extensão através de um novo extension point.

public class MyMachine
{
  public void Initialize()
  {
    // initialization logic
  }
  /* common behavior */
  
  private void ExecuteCommand(string command)
  {
    // ..
    this.ExecuteCommandExtensionPoint(command);
  }
  
  protected virtual ExecuteCommandExtensionPoint(string command)
  {
    //..
  } 
}

public class MyNewMachine : MyMachine
{
  /* any new behavior */
  
  protected sealed override void ExecuteCommandExtensionPoint
    (string command)
  {
    // ..
    base.ExecuteCommand(command);
  }
}

O “problema” do código acima é que a relação básica de herança (is-a) foi quebrada. De fato, MyNewMachine modifica MyMachine violando o princípio da substituição de Liskov (LSP). Afinal, se a inicialização incorreta fosse invocada contra a máquina antiga, teríamos um problema.

Sobrescrita de comportamentos em C#

Linguagens com suporte a POO permitem que um método de uma classe derivada tenha o mesmo nome de um método da classe base.

Por padrão, o método que será executado será aquele correspondente ao tipo da variável apontando para o objeto.

using static System.Console;

Base b = new();
Derived d = new();
Base bd = new Derived();

b.M1();  // Base M1
d.M1();  // Derived M1
bd.M1(); // Base M1

b.M2();  // Base M2
d.M2();  // Derived M2
bd.M2(); // Base M2

class Base  
{  
  public void M1()  
  {  
    WriteLine("Base - M1");  
  }  
  public void M2()  
  {  
    WriteLine("Base - M2");  
  }  
}  
  
class Derived : Base  
{
  public new void M1()  
  {  
    WriteLine("Derived - M1");  
  }
  
  public new void M2()  
  {  
    WriteLine("Derived - M2");  
  }    
}

A utilização do modificador new na declaração do método serve para “avisar” ao compilador de que estamos conscientes de que estamos sugerindo uma nova implementação e que, sabemos, que, dependendo da tipagem das variáveis essas implementações concretas podem não ser acionadas.

Eventualmente, entretanto, queremos “forçar” a sobrescrita de um comportamento. Nesses casos, é necessário indicar uma “autorização” na classe base, no método que pode ser sobrescrito e um indicativo explícito de “intenção” tal comportamento na classe derivada.

using static System.Console;

Base b = new();
Derived d = new();
Base bd = new Derived();

b.M1();  // Base M1
d.M1();  // Derived M1
bd.M1(); // Base M1

b.M2();  // Base M2
d.M2();  // Derived M2
bd.M2(); // Derived M2

class Base  
{  
  public void M1()  
  {  
    WriteLine("Base - M1");  
  }  
  public virtual void M2()  
  {  
    WriteLine("Base - M2");  
  }  
}  
  
class Derived : Base  
{
  public new void M1()  
  {  
    WriteLine("Derived - M1");  
  }
  
  public override void M2()  
  {  
    WriteLine("Derived - M2");  
  }    
}

Eventualmente, uma sobrescrita de método pode “travar” novas implementações. Em C#, esse efeito é obtido com a utilização do modificador sealed.

class Base  
{  
  public virtual void M1()  
  {  
    WriteLine("Base - M1");  
  }  
}  
  
class Derived : Base  
{
  public sealed override void M1()  
  {  
    WriteLine("Derived - M1");  
  }
}

class Derived2 : Derived
{
  public new void M1()  
  {  
    WriteLine("Derived - M1");  
  }
}

O mesmo modificador, aliás, pode e deve ser utilizado em classes em que a herança não é desejada.

sealed class Concrete  
{  
  public void M1()  
  {  
    WriteLine("Base - M1");  
  }  
} 

Usando sobrecarga de métodos para suportar múltiplos algoritmos

LSP acaba impondo restrições muito rigorosas ao design de classes com herança, principalmente, quando houverem comportamentos sobrescritos.

using System;
using System.Collections.Generic;

public class Sorter
{
  public virtual IEnumerable<T> Sort<T>(
	  IEnumerable<T> input
  ) where T: IComparable<T>
  {
     // bubblesort
  }
}

public class FasterSorter : Sorter
{
  public override IEnumerable<T> Sort<T>(
	  IEnumerable<T> input
  ) 
  {
    // quicksort
  }
}

No código do exemplo, optou-se por melhorar a performance do sistema (e performance é uma feature) sem, aparentemente, quebrar o LSP. Importante, apenas, ressaltar que, como já foi dito, mudanças de performance, aparentemente benéficas, podem ocasionar, também, problemas e instabilidades.

Usando composição como estratégia de extensão

Outra estratégia comum para criar pontos de extensão em implementações é utilizar composição de objetos.  Ou seja, permitir que um objeto faça uso de outro para completar suas responsabilidades. Boas estratégias de composição, frequentemente, envolvem a adoção do princípio da inversão de dependências (DIP)

Dependa de abstrações (interfaces), não de implementações concretas.

Adotando o padrão Strategy

Strategy é um padrão de projeto comportamental que permite que você defina uma família de algoritmos, coloque-os em classes separadas, e faça os objetos deles intercambiáveis.

public class Navigator
{
  Location _from
  public Location From 
  { 
    get => _from; 
    set 
    {
      _from = value;
      UpdateRoute();
    }
  }
  
  Location _to;
  public Location To
  {
    get => _to;
    set
    {
      _to = value; 
      UpdateRoute();
    } 
  }
  
  IRouteStrategy _routeStrategy;
  public IRouteStrategy RouteStrategy
  {
     get => _routeStrategy;
     set
     {
       _routeStrategy = value; 
       UpdateRoute()
     }
   }
   
   public Route CurrentRoute
   { get; private set;}
   
   private void UpdateRoute()
   {
      if (_to == null || _from == null || _routeStrategy == null)
      {
          CurrentRoute = null;
      }
      else
      {
         CurrentRoute = _routeStrategy.BuildRoute(_from, _to);
       }
   }
}

public interface IRouteStrategy
{
  Route BuildRote(Location from, Location to);
}

public WalkingStrategy : IRouteStrategy { ... }
public RoadStrategy : IRouteStrategy { ... }
public PublicTransportStrategy : IRouteStrategy { ... }

Adotando o padrão Specification

Specification é um padrão de design de software, onde regras de negócio podem ser recombinadas através de lógica booleana.

Imagine-se, por exemplo, tendo de implementar a regra de “aposentadoria” para um funcionário, no Brasil. Sabemos, que as regras de aposentadoria mudam com muita frequência, logo adicionar tal regra sem prever extensão parece algo inadequado.

public abstract class Employee
{
  // ...
  public abstract bool IsElegibleForRetirement();
  // ..
}

Muito melhor, é reconhecer a natureza variável dessa “regra” em uma classe específica, apartada.

public class RetirementLaw_287_2016 : IRetirementSpecification
{
  public bool IsSatisfiedBy(Employee e) { /* .. */ }
}

Usando delegates como estratégia de extensão

No exemplo anterior, tivemos duas classes concretas com a proposta de desenvolver duas implementações distintas de algoritmos de ordenação.

C# suporta a utilização de delegates como alternativa mais leve, e menos acoplada, de suportar diversidades de algoritmos.  Em Java, uma alternativa seriam os tipos anônimos.

using System;
using System.Collections.Generic;

void SortAndPrint<T>(
    IEnumerable<T> input, Sorter<T> sorter
  ) where T: IComparable<T>
{
  foreach (var e in sorter(input))
  {
  	Console.WriteLine(e);
  }
}

public delegate IEnumerable<T> Sorter<T>(
    IEnumerable<T> input
  ) where T: IComparable<T>;


public class SorterImpl
{
  public static IEnumerable<T> BubbleSort<T>(
	  IEnumerable<T> input
  ) where T: IComparable<T>
  {
	  // bubblesort
  }
	
  public static IEnumerable<T> QuickSort<T>(
	  IEnumerable<T> input
  ) where T: IComparable<T>
  {
	  // quicksort
  }
}

O ponto interessante, na adoção de delegates como ponto de extensão é que métodos que implementam os comportamentos, precisam apenas ser compatíveis com uma assinatura, sem necessidade de explicitar fidelidade a uma assinatura de contrato, o que reduz o acoplamento aferente estático.

Delegates no LINQ (.net)

O LINQ (consulta integrada à linguagem) é o nome de um conjunto de tecnologias com base na integração de recursos de consulta diretamente na linguagem C#.

Tradicionalmente, consultas feitas em dados, como em bases de dados,  são expressas como cadeias de caracteres simples sem verificação de tipo no tempo de compilação ou suporte a IntelliSense.  Com o LINQ, uma consulta é um constructo de linguagem de primeira classe, como classes, métodos, eventos. Você escreve consultas em coleções fortemente tipadas de objetos usando palavras-chave da linguagem e operadores familiares. A família de tecnologias LINQ fornece uma experiência de consulta consistente para objetos (LINQ to Objects), bancos de dados relacionais, XML e mais.

Uma das formas de utilizar LINQ é através de um conjunto amplo de métodos que, em sua maioria, aceitam delegates (Func, Action, Predicate) para especificar critérios de mapeamento, filtro e agregação.

using System;
using System.Linq;

var squares = Enumerable.Range(1, 10)
  .Select(x => x * x)
  .Where(x => x % 2 == 0);

foreach (int num in squares)
{
  Console.WriteLine(num);
}

Usando extension methods como estratégia de extensão

Extension methods permitem a “extensão” tipos existentes sem criar um novo tipo derivado, recompilar ou, caso contrário, modificar o tipo original. Os métodos de extensão são métodos estáticos, mas são chamados como se fossem métodos de instância no tipo estendido. para o código de cliente escrito em C#, F # e Visual Basic, não há nenhuma diferença aparente entre chamar um método de extensão e os métodos definidos em um tipo.

using System;

var t = 2.Hours() + 10.Minutes();
Console.WriteLine(t); // 02:10:00

public static class TimeSpanExtensionMethods
{
  public static TimeSpan Hours(this int hours)
    => new TimeSpan(hours, 0, 0);
	
  public static TimeSpan Minutes(this int minutes)
    => new TimeSpan(0, minutes, 0);
}

Antes de avançar…

Neste capítulo mostrei formas de adicionar novas funcionalidades em programas desenvolvidos com o paradigma OO, pela adição de código novo, minimizando ou até eliminando a necessidade de alterar código existente. Para isso, trabalhamos com três princípios importantes: OCP, LSP e DIP. Além disso, apresentamos características específicas de C#. Também abordamos dois padrões de design famosos: strategy specification.

Antes de avançar, proponho as seguintes reflexões:

  • Atualmente, é comum preferir usar composição em lugar de herança. Por que você acha que isso está ocorrendo?
  • Algumas pessoas consideram extension methods perigosos para o bom design. Que riscos você percebe?

Mais uma vez, lembre-se: a melhor forma de melhorar suas habilidades como programador é programando!

Referências bibliográficas

ALBAHARI, Joseph. C# 9.0 in a Nutshell: the definitive reference. San Francisco, Ca: O’Reilly, 2021.

EVANS, Eric. Domain-driven Design: tackling complexity in the heart of software. Boston, Ma: Addison-Wesley, 2002.

MARTIN, Robert. Clean CodeA Handbook of Agile Software Craftsmanship. Boston, Ma: Pearson Education, 2019.

WEISFELD, Matt. The Object-Oriented Thought Process. 5. ed. San Francisco, Ca: Addison-Wesley, 2019.

Compartilhe este capítulo:

Compartilhe:

Comentários

Participe da construção deste capítulo deixando seu comentário:

Inscrever-se
Notify of
guest
4 Comentários
Oldest
Newest Most Voted
Feedbacks interativos
Ver todos os comentários
Guilherme Bail
Guilherme Bail
2 anos atrás

Muito bom Elemar!

PS: Na section “Usando extension methods como estratégia de extensão”, você começou uma frase sem a primeira letra maiúscula.

“…Os métodos de extensão são métodos estáticos, mas são chamados como se fossem métodos de instância no tipo estendido. Para o código de cliente escrito em C#, F # e Visual Basic…”

Isaac Roque Sartori Junior
Isaac Roque Sartori Junior
2 anos atrás

Elemar, acho interessante explicitar os códigos que tendem a dar M…

Isaac Roque Sartori Junior
Isaac Roque Sartori Junior
2 anos atrás

No exemplo dos delegates, não seria interessante colocar a atribuição de uma variavel do tipo do delegate, para explicitar isso?

Elemar Júnior

Fundador e CEO da EximiaCo, atua como tech trusted advisor ajudando diversas empresas a gerar mais resultados através da tecnologia.

Mentorias

para arquitetos de software

Imersão, em grupo, supervisionada por Elemar Júnior, onde serão discutidos tópicos avançados de arquitetura de software, extraídos de cenários reais.

ElemarJúnior

Fundador e CEO da EximiaCo, atua como tech trusted advisor ajudando diversas empresas a gerar mais resultados através da tecnologia.

TECH

&

BIZ

-   Insights e provocações sobre tecnologia e negócios   -   

55 51 9 9942 0609  |  me@elemarjr.com

55 51 9 9942 0609  |  me@elemarjr.com

bullet-1.png

55 51 9 9942 0609  |  me@elemarjr.com

4
0
Quero saber a sua opinião, deixe seu comentáriox
()
x