O programador

Diversão com C#, Parte 2

Ted Neward

Ted NewardBem-vindo mais uma vez. Na minha última coluna, “Diversão com o C#” (msdn.microsoft.com/magazine/dn754595), eu falei brevemente sobre como a familiaridade com outras linguagens de programação pode ajudar a esclarecer seu pensamento sobre um problema de design que pode de outra forma parecer complicado. Eu introduzi um problema que encontrei anos atrás durante um compromisso de consultoria no qual fui requisitado a reconciliar uma lista de transações armazenadas localmente com uma lista supostamente igual de transações armazenadas remotamente. Eu tive que correspondê-las pelo valor da transação—nada mais tinha garantia de corresponder para a mesma transação—ou sinalizar os itens não correspondidos na lista de resultados.

Eu escolhi usar o F# para aquele exercício porque é uma linguagem com a qual estou familiarizado. Francamente, poderia facilmente ter sido outra linguagem como a Scala, Clojure ou Haskell. Qualquer linguagem funcional teria funcionado de forma semelhante. A chave aqui não é a própria linguagem ou a plataforma na qual ela é executada, mas os conceitos envolvidos em uma linguagem funcional. Esse foi um problema bastante funcional e amigável.

A solução em F#

Só para rever, olhe para a solução em F# na Figura 1 antes olhando para como ela seria traduzida para C#.

Olhe para a coluna anterior para ver uma recapitulação breve da sintaxe do F# usada na Figura 1, especialmente se você não está familiarizado com o F#. Eu também vou falar sobre isso enquanto o transformo no C#, portanto, você pode apenas entrar.

Figura 1 A solução em F# para resolver transações desiguais

type Transaction =
  {
    amount : float32;
    date : DateTime;
    comment : string
  }
type Register =
  | RegEntry of Transaction * Transaction
  | MissingRemote of Transaction
  | MissingLocal of Transaction
let reconcile (local : Transaction list) 
  (remote : Transaction list) : Register list =
  let rec reconcileInternal outputSoFar local remote =
    match (local, remote) with
    | [], _
    | _, [] -> outputSoFar
    | loc :: locTail, rem :: remTail ->
      match (loc.amount, rem.amount) with
      | (locAmt, remAmt) when locAmt = remAmt ->
        reconcileInternal (RegEntry(loc, rem) :: 
          outputSoFar) locTail remTail
      | (locAmt, remAmt) when locAmt < remAmt ->
        reconcileInternal (MissingRemote(loc) :: 
          outputSoFar) locTail remote
      | (locAmt, remAmt) when locAmt > remAmt ->
        reconcileInternal (MissingLocal(rem) :: 
          outputSoFar) local remTail
      | _ ->
        failwith "How is this possible?"
  reconcileInternal [] local remote

A solução em C#

No ponto de partida, você precisa dos tipos Transaction e Register. O tipo Transaction é fácil. É um tipo estrutural simples com três elementos nomeados, tornando mais fácil modelar como uma classe C#:

class Transaction
{
  public float Amount { get; set; }
  public DateTime Date { get; set; }
  public String Comment { get; set; }
}

Essas propriedades automáticas torna esta classe quase tão pequena quando seu primo F#. Com toda a sinceridade, se eu fosse realmente fazer disso uma tradução individualizada do que a versão do F# faz, eu deveria introduzir os métodos Equals e GetHashCode substituídos. No entanto, isso funcionará para o objetivo desta coluna.

As coisas ficam confusas com a união discriminada do tipo Register. Como uma enumeração no C#, uma instância do tipo Register pode ser apenas um dos possíveis valores (RegEntry, MissingLocal ou Missing­Remote). Ao contrário da enumeração C#, cada um desses valores, por sua vez, contêm dados (as duas transações que corresponderam ao RegEntry ou a transação ausente para o MissingLocal ou Missing­Remote). Embora seria fácil criar três classes em C# distintas, essas três classes devem ser de alguma forma relacionadas. Precisamos de uma lista que contém qualquer um dos três tipos—mas somente estes três—para a entrada retornada, como mostrado na Figura 2. Olá, herança.

Figura 2 Use a herança para incluir três classes distintas

class Register { }
  class RegEntry : Register
  {
    public Transaction Local { get; set; }
    public Transaction Remote { get; set; }
  }
  class MissingLocal : Register
  {
    public Transaction Transaction { get; set; }
  }
  class MissingRemote : Register
  {
    public Transaction Transaction { get; set; }
  }

Não é ridiculamente complicado, apenas mais detalhado. Se eles fossem códigos voltados para a produção, há mais alguns métodos que eu deveria adicionar—Equals, GetHashCode e, certamente, o ToString. Embora haja algumas maneiras para torná-lo um C# mais idiomático, eu escreverei o método Reconcile bem próximo a inspiração do F#. Eu olharei para as otimizações idiomáticas mais tarde.

A versão F# tem uma função “externa”, publicamente acessível recursivamente chamada para uma função interna, encapsulada. No entanto, o C# não tem conceito dos métodos aninhados. O mais próximo que posso chegar é com dois métodos—um declarado público o outro privado. Mesmo neste caso, isso não é exatamente a mesma coisa. Na versão F#, a função aninhada está encapsulada de todos, mesmo as outras funções no mesmo módulo. Mas é o melhor que podemos obter, como você pode ver na Figura 3.

Figura 3 A função aninhada está encapsulada aqui

class Program
{
  static List<Register> ReconcileInternal(List<Register> Output,
             List<Transaction> local,
             List<Transaction> remote)
  {
    // . . .
  }
  static List<Register> Reconcile(List<Transaction> local,
             List<Transaction> remote)
  {
    return ReconcileInternal(new List<Register>(), local, remote);
  }
}

Além disso, eu posso agora realizar a abordagem recursiva “escondido de todos” ao escrever a função interna totalmente como uma expressão lambda referenciada como uma variável local dentro do Reconcile. Dito isso, é provavelmente uma dependência um pouco exagerada ao original e totalmente não idiomática para o C#.

Não é algo que a maioria dos desenvolvedores do C# fariam, mas teria o mesmo efeito que a versão F#. Dentro do Reconcile­Internal, eu tenho que extrair elementos de dados usados explicitamente. Depois eu escrevo explicitamente ele em uma árvore if/else-if, em oposição a correspondência de padrão F# sucinta e concisa. No entanto, é realmente o mesmo código. Se a lista local ou remota estiver vazia, terminei de fazer o recurso. Apenas retorne a saída e pronto, como:

static List<Register> ReconcileInternal(List<Register> Output,
              List<Transaction> local,
              List<Transaction> remote)
{
  if (local.Count == 0)
    return Output;
  if (remote.Count == 0)
    return Output;

Depois, eu preciso extrair as “cabeças” de cada lista. Eu também preciso manter uma referência para o restante de cada lista (a “cauda”):

Transaction loc = local.First();
List<Transaction> locTail = local.GetRange(1, local.Count - 1);
Transaction rem = remote.First();
List<Transaction> remTail = remote.GetRange(1, remote.Count - 1);

Esse é um local onde posso introduzir um enorme impacto no desempenho se eu não tiver cuidado. As listas são imutáveis no F#, portanto, pegar a cauda de uma lista é simplesmente pegar uma referência ao segundo item na lista. Nenhuma cópia é feita.

O C#, no entanto, não tem tais garantias. Isso significa que eu poderia terminar fazendo cópias completas da lista todas as vezes. O método GetRange diz que faz “cópias rasas”, querendo dizer que ele criará uma nova Lista. No entanto, apontará para os elementos originais da transação. Isso é provavelmente o melhor que posso esperar sem me empolgar muito. Disto isso, se o código se tornar um afunilamento, torne-se tão exótico quanto for necessário.

Olhando novamente para a versão F#, o que eu realmente estou examinando na segunda correspondência de padrão são os valores na transação local e remota, como mostrado na Figura 4. Então eu extraio esses valores também e começo a compará-los.

Figura 4 A versão F# examina os valores locais e remotos

 

float locAmt = loc.Amount;
  float remAmt = rem.Amount;
  if (locAmt == remAmt)
  {
    Output.Add(new RegEntry() { Local = loc, Remote = rem });
    return ReconcileInternal(Output, locTail, remTail);
  }
  else if (locAmt < remAmt)
  {
    Output.Add(new MissingRemote() { Transaction = loc });
    return ReconcileInternal(Output, locTail, remote);
  }
  else if (locAmt > remAmt)
  {
    Output.Add(new MissingLocal() { Transaction = rem });
    return ReconcileInternal(Output, local, remTail);
  }
  else
    throw new Exception("How is this possible?");
}

Cada ramificação da árvore é muito fácil de entender neste ponto. Eu adiciono um novo elemento à lista de saída, depois faço o recurso com os elementos não processados das listas locais e remotas.

Conclusão

Se a solução em C# é assim tão elegante, por que se preocupar em parar no F# em primeiro lugar? É difícil explicar a menos que você passe pelo mesmo processo. Essencialmente, eu precisava da parada pelo F# para definir o algoritmo em primeiro lugar. Minha primeira tentativa nisso foi um desastre absoluto. Eu comecei fazendo a iteração por meio de duas listas usando os loops “foreach” duplos. Eu estava tentando controlar o estado ao longo do caminho, e eu acabei com uma enorme, fumegante confusão que eu nunca teria sido capaz de depurar em milhões de anos.

Aprender como “pensar de forma diferente” (para pegar emprestado uma linha de marketing de uma empresa de computador famosa de algumas décadas atrás) produz resultados, não a própria escolha da linguagem. Eu poderia ter facilmente contado esta história passando pela Scala, Haskell ou Clojure. O ponto não era o conjunto de recursos da linguagem, mas os conceitos atrás das linguagens mais funcionais—recursão, em particular. Isso foi o que ajudou a superar o logjam mental.

Esse é um dos motivos que os desenvolvedores deveriam aprender uma nova linguagem de programação todos os anos, como foi sugerido primeiro por um dos programadores pragmáticos, Dave Thomas, da Ruby fame. A sua mente não pode deixar de se expor a novas ideias e novas opções. Tipos semelhantes de ideias surgem quando um programador dedica algum tempo com a Scheme, Lisp ou com uma linguagem baseada na pilha como a Forth—ou com uma linguagem baseada no protótipo como a Io.

Se você desejar uma introdução leve para um número de linguagens diferentes de todas as plataformas do Microsoft .NET Framework, eu altamente recomendo o livro de Bruce Tate, “Seven Languages in Seven Weeks” (Pragmatic Bookshelf, 2010). Algumas delas você não usaria diretamente na plataforma .NET. Novamente, às vezes, a vitória está em como pensamos sobre o problemas e estruturamos a solução, não necessariamente no código reutilizável. Boa codificação.


Ted Neward é o CTO na iTrellis, uma empresa de serviços de consultoria. Ele já escreveu mais de 100 artigos e é autor e coautor de dezenas de livros, incluindo “Professional F# 2.0” (Wrox, 2010). Ele é um MVP de C# e participa como palestrante em conferências em todo o mundo. Ele consulta e orienta regularmente—entre em contato com ele el ted@tedneward.com ou ted@itrellis.com se você estiver interessado em tê-lo na sua equipe e leia seu blog em blogs.tedneward.com.

Agradecemos ao seguinte especialista técnico da Microsoft pela revisão deste artigo: Lincoln Atkinson