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.
Generics
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 valeu); 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.
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 linesCount = 0; 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 linesCount = 0; var source = EmployeeRecordEnumerable.Create(employeesStream); foreach (var e in source) { uow.Add(e); } uow.SaveChanges(); } }
Invertendo o controle!
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) { var linesCount = 0; foreach (var e in source) { _uow.Add(e); } _uow.SaveChanges(); } }
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.
Com relação aos problemas encontrados na classe procedural, um deles é que não estamos usando a classe para validar as informações que estão compondo nosso objeto, apenas formatos e estrutura de dados. Em POO, um conceito interessante é que podemos usar a classe para validar o objeto com as regras de negócio deste. De certa forma, o problema aqui é que estamos, em matéria de negócio, “enganando” o sistema e inserindo essas informações sem as devidas validações da camada de negócio, e como premissa de programação, quando enganamos o sistema, uma hora ele te “engana” de volta!
Elemar, favor colocar exemplo de interface para o sort.
Elemar, lembrar de escrever sobre as sobrecargas de métodos.