Para começar, o bom design orientado a objetos distingue o projeto das interfaces e das implementações. Pensar em interfaces implica em pensamento abstrato, enquanto a implementação é mais concreta.
Um código procedural escrito com classes
O que segue é um bom exemplo de código que, embora esteja escrito com classes é procedural. Seu propósito é “importar” para dentro do banco de dados uma relação de funcionários, com os atributos id, nome salário.
using System; using System.Data; using System.Data.SqlClient; using System.IO; public class EmployeesImporter { public void Import(Stream employeesStream) { using var reader = StreamReader(employeeStream); string line; var linesCount = 0; using var dbconn = new SqlConnection(“Data Source=(local), InitialCatalog, Integrated Security = True”); dbconn.Open(); using var transaction = dbconn.BeginTransaction(); while ((line = reader.ReadLine()) != null) { linesCount ++; var fields = line.Split(new [] {‘,’}); if (fields.Length != 3) { Console.WriteLine($“Line {linesCount}: invalid record”); continue; } if (fields[0].Length != 6) // id should be 6 chars long { Console.WriteLine($”Line {linesCount}: invalid record.”); continue; } decimal salary; if (@decimal.TryParse(fields[2], out salary)) { Console.WriteLine($”Line {linesCount}: invalid record”); continue; } var e = new EmployeeRecord(fields[0], fields[1], salary); var command = dbconn.CreateCommand(); command.Transaction = transaction; command.CommandType = CommandType.StoredProcedure; command.CommandText = “dbo.InsertEmployee”; command.Parameters.AddWithValue(“@id”, e.Id); command.Parameters.AddWithValue(“@name”, e.Name); command.Parameters.AddWithValue(“@salary”, e.Salary); command.ExecuteNonQuery(); } transaction.Commit(); dbconn.Close(); } } public struct EmployeeRecord { public EmployeeRecord(string id, string name, decimal salary) => (Id, Name, Salary) = (id, name, salary); public string Id { get; } public string Name { get; } public decimal Salary { get; } }
Este código, embora correto, tem, sob o ponto de vista orientado a objetos uma série de problemas de design, revelados em dificuldades para o manter e evoluir:
- Há forte acoplamento com uma forma e formato de receber dados. Espera-se um stream e que o conteúdo sejam linhas em formato CSV.
- Há forte acoplamento com uma tecnologia específica de persistência. No exemplo, um banco de dados SQL.
- Há forte acoplamento com uma base de dados específica, identificada por uma string de conexão.
A combinação desses diversos “acoplamentos” compromete a testabilidade e, em consequência, o evolvabilty.
Interessante observar que a interface dessa classe é mínima (há apenas um método). Mas, esta interface acaba escondendo “coisas demais” em uma única implementação que está, perceptivelmente, sobrecarregada. Essas características tornam esse código um ótimo candidato a refatoração.
Refactoring
Refatoração é um processo sistemático de melhoria de código sem criar novas funcionalidades que podem transformar uma bagunça em código limpo e design simples.
Distinguindo interface e implementação
Antes de conseguirmos melhorar o design do código anterior, precisamos afirmar alguns conceitos. Para começar, precisamos entender com mais clareza a distinção entre interfaces e implementações.
Em OO, uma interface, abstrata, constitui o conjunto de métodos (comportamento) e atributos (estado) expostos (públicos) de um objeto. É através dela que ocorrem as interações com outros objetos através de mensagens. Trata-se de uma espécie de “contrato”. No exemplo, anterior, a interface da classe EmployeesImporter é composta apenas por um método: Import
Muitas linguagens de programação, incluindo C# e Java, oferecem recursos para planejamento de interfaces especificamente, permitindo referência a elas, o que contribui para redução do acoplamento aferente para implementações concretas.
public interface IEmployeesImporter { void Import(Stream source); }
O prefixo 'I' no nome de interfaces
Uncle Bob, no clássico Clean Code, condena a utilização de adornos como o prefixo ‘I’ no nome de interfaces e apresenta bons argumentos para sua posição. Entretanto, em C#, a utilização do prefixo tornou-se prática mais do que comum, principalmente por influência do próprio framework.
A implementação, concreta, é o código que “realiza” a interface além de todos os métodos (comportamentos) e atributos (estado) privados. Ou seja, que são invisíveis para os “clientes” do objeto.
Identificando os 'clientes' de um objeto
Há duas interpretações distintas para “cliente”. A primeira, versa sobre todo código que interage com os objetos de uma determinada classe. A segunda, indica os programadores que escrevem esses códigos.
Boas interfaces são concisas e fáceis de entender. Elas comunicam com clareza a intencionalidade, mitigando riscos de interpretação e, consequentemente, erros em sistemas.
public class Sorter { // public string[] QuickSort(string[] input) string[] Sort(string[] input) { // .. } }
No código de exemplo, QuickSort é um nome ruim para o método por revelar o algoritmo que realizaria a operação de ordenação.
Generics como recurso para abstração
Generics é um importante recurso de abstração, disponível tanto em C# quanto em Java, é a escrita de classes, estruturas e métodos com parâmetros de tipo. Esse recurso diminui a duplicidade de código permitindo que uma mesma implementação concreta atue em mais de um contexto. Coleções que utilizam generics, por exemplo, permitem que o “cliente” especifique o tipo de objetos que ela armazena.
var os = new Generic<string>(); var oi = new Generic<int>(); os.Field = "Elemar"; oi.Field = 42; System.Console.WriteLine($"os.Field = \"{os.Field}\"", ); System.Console.WriteLine($"os.Field.GetType() = {os.Field.GetType().FullName}"); System.Console.WriteLine($"oi.Field = \"{oi.Field}\"", ); System.Console.WriteLine($"oi.Field.GetType() = {oi.Field.GetType().FullName}"); public class Generic<T> { public T Field; }
Uma interface, múltiplas implementações possíveis
Desviando-nos, por agora, do código inicial desse post, considere a seguinte interface:
public interface ICache<T> { void Set(string key, T value); bool TryGet(string key, out T value); }
Note que essa interface é genérica ao ponto de permitir várias implementações. Abaixo um exemplo de implementação in-memory.
using System; using System.Collections.Generic; public class InMemoryCache<T> : ICache<T> { Dictionary<string, T> _data = new Dictionary<string, T>(); public void Set(string key, T value) { _data[key] = value; } public bool TryGet(string key, out T value) => _data.TryGetValue(key, out value); }
Note que a preservação de uma interface permite diferentes implementações concretas sem que exista necessidade de mudanças ou adaptações nos clientes. A implementação de cache in-memory e in-process acima, por exemplo, poderia ser substituída facilmente por outra, distribuída, como Redis.
using System; using StackExchange.Redis; public class RedisCache<T> : ICache<T> { ConnectionMultiplexer _redis = ConnectionMultiplexer.Connect("server1:6379,server2:6379"); public void Set(string key, T value) { var o = JsonConvert.SerializeObject(value) var db = redis.GetDatabase(); o.StringSet(key, o); } public bool TryGet(string key, out T value) { var db = redis.GetDatabase(); var o = redis.StringGet(key); value = (o == null) ? default (T) : JsonConvert.DeserializeObject<T>(o); return o == null; } }
Apartar interface (abstrata) de implementação (concreta) autoriza o desenvolvimento de soluções polimórficas, com mínimo impacto de acoplamento. Essa flexibilidade permite a construção de sistemas mais adaptáveis e úteis.
Uma interface, uma responsabilidade
Uma boa orientação para elaboração de interfaces pequenas é o princípio da responsabilidade única (SRP), proposto por Uncle Bob na década de 1990.
Um módulo de software deve ter apenas uma única responsabilidade.
Adaptada para programação orientada a objetos, a proposição é que uma classe deve ter uma única responsabilidade, seja lá o que isso signifique.
O exemplo de código estruturado que inicia este capítulo mostra um método que assume muitas responsabilidades. Parte do pensamento da programação orientada a objetos consiste em distribuir responsabilidades por operações em conjuntos de objetos que cooperem efetivamente. Por exemplo, o parsing de cada linha CSV, parece mais natural a um método especialista de EmployeeRecord.
public struct EmployeeRecord { public EmployeeRecord(string id, string name, decimal salary) => (Id, Name, Salary) = (id, name, salary); public string Id { get; } public string Name { get; } public decimal Salary { get; } public static bool TryParse(string data, out EmployeeRecord result) { var fields = data.Split(new [] {‘,’}); if (fields.Length == 3 && fields[0].Length == 6 && decimal.TryParse(fields[2], out decimal salary)) { result = new EmployeeRecord(fields[0], fields[1], salary); return true; } result = default(EmployeeRecord); return false; } }
Um dos principais atributos dessa refatoração está na possibilidade de testar o parsing de dados de employees de maneira isolada. É consideravelmente mais fácil escrever rotinas automatizadas de teste para esse comportamento, afinal não há mais efeitos colaterais.
Outro aspecto importante a considerar aqui é que, na existência de uma classe para “representar” Employee, é natural que a lógica de validação esteja, então, nessa classe.
Interfaces consagradas consolidam boas práticas e padrões
Eventualmente, interfaces consagradas revelam boas práticas e padrões. Bons exemplos, são as interfaces IEnumerable<T> e IEnumerator<T>. Essas classes permitem a implementação de lazyness evaluation em .NET.
Lazyness Evaluation
Avaliação preguiçosa (também conhecida por avaliação atrasada) é uma técnica usada em programação para atrasar a computação até um ponto em que o resultado da computação é considerado necessário.
O código apresentado no início desse capítulo “lê” um stream identificando linhas CSV, interpretando-as, para, posteriormente, armazená-las no banco de dados. Poderíamos, ingenuamente, considerar uma refatoração onde processamos todo o stream para depois dispara a persistência. Entretanto, tal implementação seria uma mal ideia por materializar na memória, sem necessidade, todos os employees que precisam ser importados.
public class EmployeeRecordsEnumerableLoader { public static IEnumerable<EmployeeRecord> Load(Stream stream) { var result = new List<EmployeeRecord>(); using StreamReader _reader = new StreamReader(stream); int _linesCount = 0; string line; while ((line = _reader.ReadLine()) != null) { _linesCount++; if (!EmployeeRecord.TryParse(line, out var current)) { Console.WriteLine($"Line {_linesCount}: invalid record."); } else { result.Add(current); } } return result; } }
Empregando as abstrações propostas nas interfaces IEnumerable<T> e IEnumerator<T> podemos evitar tamanha carga. Como você poderá constatar, é muito mais código (e complexidade) para evitar desperdício de recursos computacionais.
public class EmployeeRecordsEnumerable : IEnumerable<EmployeeRecord> { Stream _stream; public EmployeeRecordsEnumerable(Stream stream) => _stream = stream; public IEnumerator<EmployeeRecord> GetEnumerator() => new EmployeeRecordsEnumerator(_stream); IEnumerator IEnumerable.GetEnumerator() => new EmployeeRecordsEnumerator(_stream); } public class EmployeeRecordsEnumerator : IEnumerator<EmployeeRecord> { StreamReader _reader; int _linesCount; public EmployeeRecordsEnumerator(Stream stream) => _reader = new StreamReader(stream); private EmployeeRecord _current; public EmployeeRecord Current { get => _current; } object IEnumerator.Current { get => _current; } public bool MoveNext() { string line; while ((line = _reader.ReadLine()) != null) { _linesCount++; if (!EmployeeRecord.TryParse(line, out var _current)) { Console.WriteLine($"Line {_linesCount}: invalid record."); } else { return true; } } return false; } public void Reset() { _reader.DiscardBufferedData(); _reader.BaseStream.Seek(0, SeekOrigin.Begin); _current = default(EmployeeRecord); } public void Dispose() { _reader.Dispose(); } }
Essa implementação, agora, permite simplificação considerável da lógica de EmployeesImporter. Repare como o foreach entende e consegue lidar com as interfaces IEnumerable!
public class EmployeesImporter { public void Import(Stream employeesStream) { using var dbconn = new SqlConnection(“Data Source=(local), InitialCatalog, Integrated Security = True”); dbconn.Open(); using var transaction = dbconn.BeginTransaction(); var source = new EmployeeRecordEnumerable(employeesStream); foreach (var e in source) { var command = dbconn.CreateCommand(); command.Transaction = transaction; command.CommandType = CommandType.StoredProcedure; command.CommandText = “dbo.InsertEmployee”; command.Parameters.AddWithValue(“@id”, e.Id); command.Parameters.AddWithValue(“@name”, e.Name); command.Parameters.AddWithValue(“@salary”, e.Salary); command.ExecuteNonQuery(); } transaction.Commit(); dbconn.Close(); } }
Concordemos, porém, que as implementações de IEnumerable<T> e IEnumerator<T> não são, evidentemente, fáceis. Felizmente, C#, oferece um “açúcar sintático” para simplificar essa escrita: yield return.
public class EmployeeRecordsEnumerableFactory { public static IEnumerable<EmployeeRecord> Create(Stream stream) { using StreamReader _reader = new StreamReader(stream); int _linesCount = 0; string line; while ((line = _reader.ReadLine()) != null) { _linesCount++; if (!EmployeeRecord.TryParse(line, out var current)) { Console.WriteLine($"Line {_linesCount}: invalid record."); } else { yield return current; } } } }
Problemas comuns geralmente tem implementações simples! O código acima faz com que o compilador gere “por baixo dos panos” implementações concretas para IEnumerable<T> e IEnumerator<T> que se comportam conforme a implementação indicada.
Implementações concretas devem ser uniformes no “nível de abstração”
Quando “apartamos” a leitura das linhas do stream e o parsing dos dados, simplificamos consideravelmente a implementação do método Importer tornado-o uma espécie de “orquestrador abstrato” para atividades de nível mais baixo (mais concretas). Entretanto, o acesso ao banco de dados continua sendo feito diretamente. A separação dessa “responsabilidade” pode ser feita através da implementação do padrão Unit of Work.
Unit of Work
Unit of Work é um padrão de projeto para aplicações empresariais. A ideia desse padrão é manter uma lista de objetos relacionados a uma transação e coordenar sua persistência.
public class UnitOfWorkSql : IDisposable { SqlConnection _dbconn; SqlTransaction _transaction; public UnitOfWorkSql() { _dbconn = new SqlConnection(“Data Source=(local), InitialCatalog, Integrated Security = True”); _dbconn.Open(); _transaction = _dbconn.BeginTransaction(); } public void Add(EmployeeRecord record) { var command = _dbconn.CreateCommand(); command.Transaction = _transaction; command.CommandType = CommandType.StoredProcedure; command.CommandText = “dbo.InsertEmployee”; command.Parameters.AddWithValue(“@id”, record.Id); command.Parameters.AddWithValue(“@name”, record.Name); command.Parameters.AddWithValue(“@salary”, record.Salary); command.ExecuteNonQuery(); } public void SaveChanges() { _transaction.Commit(); _transaction.Dispose(); _transaction = _dbconn.BeginTransaction(); } public void Dispose() { _transaction.Dispose(); _dbconn.Close(); _dbconn.Dispose(); } }
A adoção desse padrão permite retirar o código que lida com banco de dados da classe Importer tornando-a menos abstrata.
public class EmployeesImporter { public void Import(Stream employeesStream) { using var uow = new UnitOfWorkSql(); var source = EmployeeRecordEnumerable.Create(employeesStream); foreach (var e in source) { uow.Add(e); } uow.SaveChanges(); } }
Invertendo o controle!
Uma boa orientação para adoção de abordagens polimórficas é o princípio da injeção de dependências (DIP).
Separe o comportamento extensível por trás de uma interface e inverta as dependências. (Uncle Bob)
Considerando o exemplo que estamos trabalhando neste capítulo, a extração da interface do UnitOfWork, por exemplo, torna muito fácil gravar dados em outros formatos e lugares, além do banco de dados proposto originalmente. O “segredo” está em substituir a alocação dessa dependência pela possibilidade de recebê-la por parâmetro na implementação concreta da classe Importer.
public class EmployeesImporter { IUnitOfWork _uow; public EmployeesImporter(IUnitOfWork uow) => _uow = uow; public void Import(Stream employeesStream) => Import(EmployeeRecordEnumerable.Create(employeesStream)); public void Import(IEnumerable<EmployeeRecord> source) { foreach (var e in source) { _uow.Add(e); } _uow.SaveChanges(); } }
Outro aspecto importante a observar nesse código é o suporte da linguagem C#, que também existe em Java, de sobrecarga para métodos. Ou seja, a possibilidade de expressar um mesmo comportamento, em diferentes implementações, ajustando a lista de parâmetros. Essa capacidade, torna o código mais expressivo e permite a adoção do pensamento de que um mesmo comportamento pode ser disparado por mais de um tipo de mensagem.
Repare que:
- Não há forte acoplamento com uma forma e formato de receber dados. Em nível mais alto, espera-se uma enumeração de EmployeeRecords.
- Não há forte acoplamento com uma tecnologia específica de persistência. Espera-se apenas um objeto que implemente uma UnitOfWork apropriadamente.
- Não há forte acoplamento com uma base de dados específica, ela ficou localizada na UnitOfWork podendo ser, ainda, abstraída.
Antes de avançar…
Design orientado a objetos de qualidade demanda experiência que só é obtida pela prática.
Caso tenha alguma experiência utilizando linguagens que favorecem orientação a objetos, submeta seus códigos a uma análise crítica para determinar se são, de fato OO ou, apenas código com classes. Se tiver tempo, busque implementar algumas refatorações.
Sobrecarga de métodos! =)
Excelente o artigo Elemar! Obrigado!
PS: Poderia por favor arrumar esse nome da variavel na interface ICache<T>?
para
Elemar, em determinadas partes do código você utiliza a instrução “var” e em outras “using var”, poderia complementar o texto com uma breve explicação sobre a diferença.
Obrigado!
Ótima aula Elemar, muito obrigado por dividir o conhecimento.
PS: No exemplo há a variável linesCount que é inicializada mas não é utilizada. Poderia realizar o ajuste?
Feito!