msdn01_capa.JPG

Clique aqui para ler todos os artigos desta edição

 

 Introduzindo os “Generics” no CLR

por Jason Clark

Vamos dar uma espiada no futuro, examinando um novo e excelente recurso, os genéricos (generics). Esse recurso em breve fará parte do CLR (common language runtime). Apresentaremos os genéricos e discutiremos os benefícios que eles proporcionam ao código.

Genéricos consistem em uma extensão do sistema de tipos do CLR que permite aos desenvolvedores definir tipos para os quais determinados detalhes ficam sem especificação. Na verdade, esses detalhes são especificados quando a referência ao código é feita pelo código do consumidor. O código que faz referência ao tipo genérico preenche os detalhes que faltavam, modelando o tipo conforme as necessidades específicas. O nome “Generics” reflete a meta do recurso: permitir escrever códigos sem especificar detalhes que possam limitar o escopo de sua utilidade. O código em si é genérico. Darei mais detalhes sobre isso um pouco mais adiante.

Quando serão lançados os genéricos? A Microsoft pretende lançar os genéricos na versão do CLR apelidada de “Whidbey”, e provavelmente será lançada uma versão beta do CRL Whidbey depois desta coluna ser publicada. Ao mesmo tempo, na mesma versão do CLR, foi programada uma atualização na linguagem e no compilador que aproveitará totalmente os genéricos. Por fim, o grupo de pesquisa na Microsoft modificou uma versão da CLI (common language implementation) de origem compartilhada, apelidada de “Rotor”, para que incluísse suporte aos genéricos. Esse runtime modificado, chamado de "Gyro", encontra-se disponível em http://research.microsoft.com/projects/clrgen.

 

Primeiras impressões sobre os genéricos

É sempre importante saber a utilidade de uma nova tecnologia. Quem estiver familiarizado com os modelos em C++ verá que os genéricos têm a mesma utilidade do código gerenciado. No entanto, não gosto muito de comparar os genéricos do CLR com os modelos do C++, pois os genéricos possuem alguns benefícios adicionais, entre os quais a ausência de dois problemas comuns: excesso de código (code bloat) e confusão para o desenvolvedor.

Alguns pontos fortes dos genéricos do CLR são a segurança de tipos durante a compilação, a reutilização de código binário, o desempenho e a clareza. Esses benefícios serão descritos em breve e, à medida que for lendo esta coluna, você conhecerá mais detalhes sobre eles. Vamos tomar como exemplo duas classes de coleção hipotéticas. SortedList, uma coleção de referências Object, e GenericSortedList, uma coleção de qualquer tipo.

Segurança de tipos

Quando um usuário adiciona uma String a uma coleção do tipo SortedList, ocorre uma conversão (cast) implícita para Object. Da mesma forma, se um objeto String é recuperado da lista, ele deve ser convertido em tempo de execução de uma referência Object para uma referência String. Essa falta de segurança de tipos durante a compilação, além de tediosa para o desenvolvedor, é propensa a erros. Por outro lado, o uso de GenericSortedList onde T é tipificado como String faz com que todos os métodos add e lookup trabalhem com referências String. Isso permite a especificação e a verificação do tipo dos elementos durante a compilação, e não em tempo de execução.

Reutilização de código binário

Para fins de manutenção, um desenvolvedor pode escolher obter segurança de tipos durante a compilação usando SortedList, derivando dela uma SortedListOfStrings. O problema dessa abordagem é que o novo código precisa ser escrito para cada tipo para o qual se deseje uma lista de segurança de tipos, o que pode acabar sendo trabalhoso. Com GenericSortedList, tudo o que você precisa fazer é instanciar como T o tipo que possui o tipo de elemento desejado. Como um valor adicionado, o código generics é gerado em tempo de execução, então, duas expansões sobre tipos de elementos não relacionados, como GenericSortedList e GenericSortedList, podem reutilizar a maior parte do mesmo código compilado JIT (just-in-time). O CLR simplesmente ajusta esses detalhes – sem excesso de código!

Desempenho

No final das contas: se a verificação de tipos for feita durante a compilação em vez de em tempo de execução, o desempenho será melhor. No código gerenciado, as conversões entre referências e valores estão sujeitas a boxings e unboxings, e evitar essas conversões pode causar um impacto igualmente negativo sobre o desempenho. As avaliações de desempenho atuais para um tipo de matriz de um milhão de inteiros mostra que o método genérico é três vezes mais rápido que seu equivalente não-genérico. Isso ocorre porque o boxing dos valores é totalmente evitado. O mesmo tipo em uma matriz de referências de string resultou em um aprimoramento de 20 por cento sobre o desempenho com o método genérico, pois não houve necessidade de realizar a verificação de tipos em tempo de execução.

Objetividade

Com os genéricos, a objetividade se apresenta de várias formas. Nos genéricos, as restrições consistem em um recurso que tem o efeito de impossibilitar expansões incompatíveis de código genérico. Com os genéricos, você nunca enfrentará os erros de compilação de criptografia que castigam os usuários do modelo C++. No exemplo de GenericSortedList, a classe collection teria uma restrição que só lhe permitiria funcionar com tipos para T que pudessem ser comparados e, portanto, classificados. Além disso, os métodos genéricos podem muitas vezes ser chamados sem que se use uma sintaxe especial, por meio de um recurso chamado interferência de tipo (type interference). E, evidentemente, a segurança de tipos durante a compilação aumenta a objetividade no código do aplicativo. As restrições, a interferência de tipo e a segurança de tipos serão analisadas em detalhes nesta coluna.

Um exemplo simples

O CLR Whidbey oferecerá esses benefícios instantaneamente, com um conjunto de classes collection genéricas na biblioteca de classes. Mas os aplicativos podem se beneficiar ainda mais, definindo seu próprio código genérico. Para examinar como isso é feito, começarei por modificar uma classe de nó de lista vinculada simples para torná-la um tipo de classe genérica. A classe Node na Listagem 1 inclui pouco mais que o básico. Ela contém um campo, m_data, que se refere aos dados do nó, e um segundo campo, m_next, que se refere ao próximo item na lista vinculada. Ambos os campos são definidos pelo método constructor. Há somente duas novas características, sendo a primeira o fato de que os campos m_data e m_next são acessados por meio das propriedades somente leitura denominadas Data e Next. A segunda é a substituição do método virtual ToString do System.Object.

A Listagem 1 também mostra o código que usa a classe Node. Esse código de referência possui algumas limitações. O problema é que, para que seja utilizável em muitos contextos, esses dados devem ser do tipo mais básico, System.Object, porque quando você usa Node, perde toda forma de segurança de tipos durante a compilação. Usar Object para representar “qualquer tipo” em um algoritmo ou estrutura de dados força o código de consumo a se converter entre referências Object e o verdadeiro tipo de dados. Os erros de não-correspondência de tipos (type-mismatch) no aplicativo só serão detectados em runtime, quando então assumirão a forma de um InvalidCastException no ponto em que se tentar a conversão. Além disso, qualquer atribuição de um valor primitivo como um Int32 a uma referência Object requer um boxing da instância. O boxing envolve uma alocação de memória e uma cópia de memória, assim como a conseqüente coleta de lixo do valor na box. Por fim, as conversões de referências Object em tipos de valores como Int32, como você pode ver na Listagem 1, são sujeitas a unboxing, o que também inclui uma verificação de tipo. Como o boxing e o unboxing prejudicam o desempenho geral do algoritmo, você pode ver por que há desvantagens no uso de Object para representar “qualquer tipo”.

Listagem 1. Lista vinculada simples

 

using System;

 

// Definition of a node type for creating a linked list

class Node {

   Object  m_data;

   Node    m_next;

 

   public Node(Object data, Node next) {

      m_data = data;

      m_next = next;

   }

 

   // Access the data for the node

   public Object Data {

      get { return m_data; }

   }

 

   // Access the next node

   public Node Next {

      get { return m_next; }

   }

 

   // Get a string representation of the node

   public override String ToString() {

      return m_data.ToString();

   }           

}

 

// Code that uses the node type

class App {

   public static void Main() {

 

      // Create a linked list of integers

      Node head = new Node(5, null);

      head = new Node(10, head);

      head = new Node(15, head);

 

      // Sum-up integers by traversing linked list

      Int32 sum = 0;

      for (Node current = head; current != null;

         current = current.Next) {

         sum += (Int32) current.Data;

      }     

 

      // Output sum

      Console.WriteLine("Sum of nodes = {0}", sum);     

   }

}


Reescrever Node usando genéricos é uma maneira elegante de lidar com esses problemas. Dê uma olhada no código na Listagem 2 e você verá o tipo Node reescrito como o tipo Node. Um tipo com comportamentos genéricos como Node é parametrizado e pode ser chamado de Parameterized Node, Node of T (Nó de T) ou Generic Node. Tratarei da nova sintaxe do C# em instantes; primeiro, vamos nos aprofundar na diferença entre Node e Node. O tipo Node é funcional e estruturalmente semelhante ao tipo Node. Ambos oferecem suporte à criação de listas vinculadas de qualquer tipo de dados fornecido. No entanto, onde Node usa System.Object para representar “qualquer tipo”, Node deixa o tipo sem especificação. Em vez disso, Node usa um parâmetro de tipo chamado T que é um espaço reservado para um tipo. O parâmetro de tipo é depois especificado por um argumento como Node quando o código de consumo utiliza Node.

Listagem 2. Definição de código: Nó de lista vinculada genérico

class Node {

   T        m_data;

   Node  m_next;

 

   public Node(T data, Node next) {

      m_data = data;

      m_next = next;

   }

 

   // Access the data for the node

   public T Data {

      get { return m_data; }

      set { m_data = value; }

   }

 

   // Access the next node

   public Node Next {

      get { return m_next; }

      set { m_next = value; }

   }

 

   // Get a string representation of the node

   public override String ToString() {

      return m_data.ToString();

   }           

}


O código na Listagem 3 utiliza Node com inteiros sinalizados de 32 bits, construindo um nome de tipo como este: Node. Nesse exemplo, Int32 é o argumento de tipo para o parâmetro de tipo T. (A propósito, C# também aceitaria Node para indicar T como Int32.) Se o código precisasse de uma lista vinculada de algum outro tipo, como referências String, ele poderia fazer isso especificando-a como o argumento de tipo para T, da seguinte maneira: Node. A superioridade de Node é que os comportamentos de seus algoritmos são bem definidos, ao passo que o tipo de dados que ele opera permanece sem especificação. Dessa forma, o tipo Node é específico em termos de modo de funcionamento, mas genérico em termos dos itens com que trabalha. De qualquer maneira, detalhes como o tipo de dados que uma lista vinculada deve conter serão realmente mais bem especificados pelo código que usa Node.

Listagem 3. Código de referência: Nó de lista vinculada genérico

class App {

   public static void Main() {

 

      // Create a linked list of integers

      Node head = new Node(5, null);

      head = new Node(10, head);

      head = new Node(15, head);

 

      // Sum up integers by traversing linked list

      Int32 sum = 0;

      for (Node current = head; current != null;

         current = current.Next) {

         sum += current.Data;

      }     

 

      // Output sum

      Console.WriteLine("Sum of nodes = {0}", sum.ToString());     

   }

}

Ao tratar de genéricos, pode ser útil ser explícito sobre duas funções: código de definição e código de referência. O código de definição inclui o código que tanto declara a existência do código genérico quanto define os membros do tipo, como métodos e campos. O código mostrado na Listagem 2 é o código de definição para o tipo Node. O código de referência é o código de consumidor que utiliza código genérico predefinido que também pode ser incorporado a outro assembly. A Listagem 3 é um exemplo de código de referência para Node.

Essas duas funções são consideradas úteis porque tanto o código de definição como o de referência fazem parte da verdadeira construção do código genérico utilizável. O código de referência na Listagem 3 usa Node para construir um novo tipo chamado Node. Node é um tipo distinto que foi criado com dois ingredientes principais: Node, criado pelo código de definição, e o argumento de tipo Int32 para o parâmetro T especificado pelo código de referência. Somente esses dois ingredientes fazem o código genérico se completar. Observe que os tipos genéricos, como Node, e os tipos construídos a partir do tipo genérico, como Node ou Node, não são tipos relacionados em termos de derivação orientada a objeto. Os tipos Node, Node e Node são todos irmãos, derivados diretamente de System.Object.

Sintaxe genérica do C#

O CLR oferece suporte a várias linguagens de programação, por isso há várias sintaxes para genéricos do CLR. No entanto, independentemente da sintaxe, o código genérico escrito em uma linguagem voltada para CLR poderá ser utilizado por programas escritos em outras linguagens. Tratarei da sintaxe do C# aqui porque no momento em que este texto estava sendo redigido, no que concerne os genéricos, ela era a mais estável dentre as três grandes linguagens gerenciadas. No entanto, vale a pena observar que tanto o Visual Basic® .NET como o Managed C++ devem oferecer suporte aos genéricos em suas versões Whidbey.
   A Tabela 1 mostra a sintaxe básica do C# para o código de definição genérico e para o código de referência genérico. As diferenças de sintaxe entre os dois refletem as diferentes responsabilidades das duas partes envolvidas com o código genérico.

 

 

 

 

Tabela 1. Sintaxe genérica essencial do C#.

Código de definição

Código de referência

class Node<T> {   T        m_data;   Node<T>  m_next;} class Node8Bit : Node<Byte> { •••}
struct Pair<T,U> {   T  m_element1;   U  m_element2;} Pair<Byte,String> pair;pair.m_element1 = 255;pair.m_element2 = "Hi";
interface IComparable<T> {   Int32 CompareTo(T other);} class MyType : IComparable<MyType> {    public Int32 CompareTo(MyType other)   { ... }}
void Swap(ref T item1, ref T item2) {   T temp = item1;   item1 = item2; item2 = temp;} Decimal d1 = 0, d2 = 2;Swap<Decimal>(ref d1, ref d2);
delegate void EnumerateItem(T item); •••   EnumerateItem<Int32> callback =      new EnumerateItem<Int32>(CallMe);}void CallMe(Int32 num) { ... }

 

 

O plano atual é que o CLR e, portanto, o C#, ofereçam suporte a classes, estruturas, métodos, interfaces e delegates genéricos. A lateral esquerda da Tabela 1 mostra um exemplo da sintaxe do C# para cada um desses casos de código de definição. Observe que os colchetes angulares (sinais de maior e menor) denotam uma lista de parâmetros de tipo. Os sinais de menor e maior aparecem imediatamente após o nome do membro ou tipo genérico. Há também um ou mais parâmetros de tipo na lista de parâmetros de tipo. Os parâmetros também aparecem em toda a definição do código genérico, seja no lugar de um tipo CLR específico ou como um argumento para um construtor de tipos. A lateral direita da Tabela 1 mostra exemplos da sintaxe do C# para os casos onde existem códigos de referência correspondentes. Observe que os colchetes angulares contêm argumentos de tipos; um identificador genérico acrescido de colchetes constrói um novo e distinto identificador. Observe também que os argumentos de tipos especificam quais tipos usar na construção de um tipo ou método a partir de um genérico.

Vamos agora dedicar algum tempo à sintaxe do código de definição. O compilador reconhece que você está definindo um tipo ou método genérico quando encontra uma lista de parâmetros de tipo delimitada por colchetes angulares. Os colchetes angulares em uma definição genérica vêm logo após o nome do tipo ou do método que está sendo definido. Uma lista de parâmetros de tipo indica um ou mais tipos que você deseja manter sem especificação na definição do código genérico. Os nomes dos parâmetros de tipo podem ser qualquer identificador válido no C# e são separados por vírgulas. Veja aqui algumas observações relacionadas aos parâmetros de tipo da seção Código de definição da Tabela 1:

·         Em cada exemplo de código, você verá que os parâmetros de tipo T ou U são usados nas definições nos locais em que normalmente apareceria um nome de tipo.

·         No exemplo da interface IComparable, você pode verificar o uso de um parâmetro de tipo T e de um tipo regular Int32. Nas definições de código genérico, você pode combinar ambos os tipos não especificados por meio de parâmetros de tipo e de tipos especificados, usando um nome de tipo CLR.

·         Você pode ver no exemplo de Node que o parâmetro de tipo T pode ser usado sozinho (como na definição de m_data) e também como parte de outra construção de tipo (como no caso de m_next). O uso de um parâmetro de tipo como argumento para outra definição de tipo genérico, como Node, é chamado de tipo genérico aberto. O uso de um tipo concreto como um argumento de tipo, como Node, é chamado de tipo genérico fechado.

·         Como qualquer outro método genérico, o método genérico do exemplo, Swap, conforme visto na Tabela 1, pode ser parte de um tipo genérico ou não-genérico e pode ser um método estático, virtual ou de instância.


Nesta coluna, para manter as coisas mais simples, estão sendo usados nomes de caracteres únicos, como T e U, para os parâmetros de tipo. No entanto, você verá que também é possível usar nomes descritivos. Por exemplo, o tipo Node poderia ser definido de forma equivalente como Node ou Node no código de produção. Quando este texto foi escrito, a Microsoft tinha padronizado os nomes de parâmetros de tipo com caracteres únicos no código de biblioteca para ajudar a distinguir esses nomes dos que eram utilizados em tipos comuns. Pessoalmente, no caso de código de produção, gosto de parâmetros do tipo camelCasing, pois ele permite a distinção dos nomes de tipos simples no código, sem que se perca a forma descritiva.

É no código de referência genérico que o não-especificado se torna especificado. Isso é necessário quando o código de referência realmente usa código genérico. Se você examinar os exemplos na seção Código de referência da Tabela 1, verá que, em todos os casos, um novo tipo ou método será construído a partir de um genérico, por meio da especificação dos tipos de CLR como argumentos de tipo para o genérico. Na sintaxe genérica, códigos como Nodee Pair denotam nomes de tipos para novos tipos construídos a partir de uma definição de tipo genérico. Há ainda mais um detalhe de sintaxe que desejo abordar antes de me aprofundar na tecnologia propriamente dita. Quando o código chama um método genérico como o método Swap da Tabela 1, a sintaxe de chamada totalmente qualificada inclui todos os argumentos de tipo. No entanto, às vezes é possível excluir opcionalmente os argumentos de tipo da sintaxe de chamada, como mostram estas duas linhas de código:

Decimal d1 = 0, d2 = 2;

Swap(ref d1, ref d2);

 

Essa sintaxe de chamada simplificada conta com um recurso do compilador do C# chamado de inferência de tipo, através do qual o compilador usa tipos de parâmetros passados ao método para inferir os argumentos de tipo. Nesse caso, o compilador infere que, a partir dos tipos de dados de d1 e d2, o argumento de tipo para o parâmetro de tipo T deve ser System.Decimal. Se houver ambigüidades, a inferência de tipo não funcionará para quem fizer a chamada, e o compilador do C# produzirá um erro, sugerindo que você use a sintaxe de chamada completa, com os sinais de maior e menor e os argumentos de tipo.


Direcionamento

Um grande amigo meu adora dizer que as soluções de programação mais elegantes envolvem a inclusão de um nível extra de direcionamento. Os ponteiros e referências permitem que uma única função afete mais de uma instância de uma estrutura de dados. As funções virtuais permitem que um único local de chamada roteie chamadas para um conjunto de métodos semelhantes – alguns dos quais definidos mais adiante. Esses dois exemplos de direcionamento são tão comuns que o direcionamento em si muitas vezes nem é notado pelo programador.

Um dos principais objetivos do direcionamento é aumentar a flexibilidade do código. Os genéricos são uma forma de direcionamento na qual a definição não resulta em código diretamente utilizável. Na verdade, ao definir o código genérico, é criada uma “fábrica de códigos”. O código da fábrica pode então ser usado pelo código de referência para construir um código que possa ser usado diretamente.

Vamos analisar essa idéia primeiro com um método genérico. O código na Listagem 4 define um método genérico chamado CompareHashCodes e faz referência a ele. O código de definição cria um método genérico chamado CompareHashCodes, mas nenhum dos códigos mostrados na Listagem 4 chama CompareHashCodes diretamente. Em vez disso, CompareHashCodes é usado pelo código de referência em Main para construir dois métodos distintos: CompareHashCodes e CompareHashCodes. Esses métodos construídos são instâncias de CompareHashCodes e são chamados pelo código de referência.

 

Listagem 4. Direcionamento como um método genérico

using System;

 

class App {

   public static void Main() {

      Boolean result;

 

      // Constructs an instance of CompareHashCodes for Int32

      result = CompareHashCodes(5, 10);

 

      // Constructs an instance of CompareHashCodes for String

      result = CompareHashCodes("Hi there", "Hi there");

   }

 

   // Generic method, a "method factory" of sorts

   static Boolean CompareHashCodes(T item1, T item2) {

      return item1.GetHashCode() == item2.GetHashCode();

   }

}

 

Normalmente, na definição direta de um método já se estabelece o que o método faz. Uma definição de método genérico, por outro lado, estabelece o que farão as instâncias do método construído. O método genérico por si só não faz nada, mas age como um modelo para a construção de instâncias específicas. CompareHashCodes é um método genérico a partir do qual são construídas instâncias do método que comparam Hash Codes. Uma instância construída, como CompareHashCodes, executa o verdadeiro trabalho: compara Hash Codes de inteiros. Em compensação, CompareHashCodes é rebaixado um nível de direcionamento na possibilidade de ser chamado.

Da mesma forma, os tipos genéricos ficam um nível de direcionamento mais distante de seus equivalentes simples. Uma definição de tipo simples, como uma classe ou estrutura, é usada pelo sistema para criar objetos na memória. Por exemplo, o tipo System.Collection.Stack na biblioteca de classes é usado para criar objetos de pilha na memória. De certa forma, você pode considerar a nova palavra-chave no C# ou a instrução newobj no código de linguagem intermediária como uma fábrica de objetos que cria instâncias de objetos usando um tipo gerenciado como um esboço de cada objeto.

Por outro lado, os tipos genéricos são usados para instanciar tipos fechados, em vez de instâncias de objetos. Um tipo construído a partir de um tipo genérico pode então ser usado para criar objetos. Vamos recapitular o tipo Node type definido na Listagem 2 junto com seu código de referência mostrado na Listagem 3. Um aplicativo gerenciado pode sempre criar um objeto do tipo Node, apesar de ser um tipo gerenciado. Isso ocorre porque Node não tem definição suficiente para ser instanciado como um objeto na memória. No entanto, Node pode ser usado para instanciar outro tipo durante a execução de um aplicativo. Node é um tipo genérico aberto e só é usado para criar outros tipos construídos. Se um tipo construído criado com o uso de Node é fechado, como Node, ele pode ser usado para criar objetos. O código de referência na Listagem 3 usa Node de forma muito semelhante a um tipo simples. Ele cria objetos do tipo Node, chama métodos nos objetos etc. A camada de direcionamento extra que os tipos genéricos fornecem é um recurso muito eficaz. O código de referência que usa os tipos genéricos resulta em tipos gerenciados feitos sob medida. Construir o código genérico na sua mente como um nível de direcionamento removido de seu equivalente simples o ajudará a intuir muitos dos comportamentos, regras e usos dos genéricos no CLR.

 

Conclusão

Neste artigo, você conheceu os benefícios dos tipos genéricos – como seu uso pode aprimorar a segurança de tipos, a reutilização de código e o desempenho. Você também teve uma noção da sintaxe no C# e viu como os genéricos levam a outro nível de direcionamento, o que resulta em maior flexibilidade.