C# 3.0
A evolução do LINQ e seu impacto no design do C#
Anson Horton
Este artigo se baseia em uma versão de pré-lançamento do Visual Studio com o codinome "Orcas". Todas as informações contidas neste documento estão sujeitas a alterações.
Este artigo aborda:
- C# e LINQ
- A evolução do LINQ
- Consulta SQL a partir de código
|
Este artigo utiliza as seguintes tecnologias:
LINQ, C#
|

Conteúdo
Eu era um grande fã da série Connections, de James Burke, quando ela era transmitida pelo Discovery Channel. Sua premissa básica era saber o quanto descobertas aparentemente sem relação influenciaram outras descobertas, resultando em confortos materiais nos dias de hoje. A moral, por assim dizer, é que nenhum avanço é feito isoladamente. Como era de se esperar, isso também se aplica ao LINQ (Language Integrated Query).
Em termos simples, o LINQ é uma série de extensões de linguagem que oferecem suporte à consulta de dados em um meio de tipo seguro. Ele será lançado com a próxima versão do Visual Studio, de codinome "Orcas". Os dados a serem consultados podem ter o formato de XML (LINQ para XML), bancos de dados (ADO.NET habilitado para LINQ, que inclui LINQ para SQL, LINQ para Conjunto de dados e LINQ para Entidades), objetos (LINQ para Objetos), etc. A Figura 1 mostra a arquitetura do LINQ.
Figura 1 Arquitetura do LINQ (Clique na imagem para aumentar a exibição)
Vamos examinar alguns códigos. Uma consulta LINQ de exemplo na futura versão "Orcas" do C# teria a seguinte aparência:
var overdrawnQuery = from account in db.Accounts
where account.Balance < 0
select new { account.Name, account.Address };
Se os resultados dessa consulta fossem iterados com o uso de foreach, cada elemento retornado consistiria em um nome e endereço de uma conta com saldo menor que 0.
O exemplo acima mostra claramente que a sintaxe é parecida com SQL. Vários anos atrás, Anders Hejlsberg (projetista-chefe do C#) e Peter Golde pensaram em estender o C# para integrar melhor a consulta de dados. Peter, que era o gerente de desenvolvimento do compilador C# na época, estava avaliando a possibilidade de torná-lo extensível, especificamente para dar suporte a suplementos que pudessem verificar a sintaxe de linguagens específicas de domínio, como o SQL. Anders, por outro lado, estava imaginando um nível de integração mais profundo e específico. Ele estava pensando em um conjunto de "operadores de seqüência" que funcionariam em qualquer coleção que implementasse IEnumerable, assim como consultas remotas para tipos que implementassem IQueryable. No final das contas, a idéia dos operadores de seqüência ganhou o apoio maior e, no início de 2004, Anders enviou um documento sobre a idéia para a Thinkweek de Bill Gates. Os comentários foram predominantemente positivos. Nos estágios iniciais do design, uma consulta simples tinha a seguinte sintaxe:
sequence<Customer> locals = customers.where(ZipCode == 98112);
Nesse caso, a seqüência era um alias para IEnumerable<T>, e a palavra "where" era um operador especial compreendido pelo compilador. A implementação do operador where era um método estático normal de C# que usava um delegado de predicado (ou seja, um delegado da forma bool Pred<T>(item T)). A idéia era que o compilador tivesse conhecimento especial sobre o operador. Isso permitiria que o compilador chamasse corretamente o método estático e criasse o código para ligar o delegado à expressão.
Vamos supor que o exemplo acima fosse a sintaxe ideal para uma consulta no C#. Qual seria a aparência dessa consulta no C# 2.0, sem nenhuma extensão de linguagem?
IEnumerable<Customer> locals = EnumerableExtensions.Where(customers,
delegate(Customer c)
{
return c.ZipCode == 98112;
});
Esse código é exageradamente detalhado. E mais: requer uma pesquisa considerável para localizar o filtro pertinente (ZipCode == 98112). E olha que este exemplo é simples! Imagine o quanto pioraria a legibilidade se houvesse vários filtros, projeções, etc. A origem do detalhamento é a sintaxe necessária para métodos anônimos. Na consulta ideal, a expressão não precisaria de nada além da expressão a ser avaliada. O compilador então tentaria inferir o contexto; por exemplo, se ZipCode estava realmente se referindo ao ZipCode definido em Customer. Como corrigir esse problema? A codificação do conhecimento de operadores específicos na linguagem não foi bem aceita pela equipe de design de linguagem, que começou a procurar uma sintaxe alternativa para métodos anônimos. Eles queriam que ela fosse extremamente concisa, ainda que não exigisse obrigatoriamente um conhecimento maior do que o necessitado atualmente pelo compilador para métodos anônimos. Finalmente, eles desenvolveram as expressões lambda.
Expressões lambda
As expressões lambda são um recurso de linguagem semelhante, em diversos aspectos, aos métodos anônimos. Na verdade, se as expressões lambda tivessem sido introduzidas antes na linguagem, não haveria necessidade dos métodos anônimos. A idéia básica é que se possa tratar código como dados. No C# 1.0, é comum passar cadeias de caracteres, inteiros, tipos de referência, etc. para métodos a fim de que eles possam atuar sobre esses valores. Os métodos anônimos e as expressões lambda estendem o intervalo de valores para incluir blocos de código. Esse conceito é comum na programação funcional.
Vamos pegar o exemplo acima e substituir o método anônimo por uma expressão lambda:
IEnumerable<Customer> locals =
EnumerableExtensions.Where(customers, c => c.ZipCode == 91822);
Há várias coisas a serem observadas. Antes de tudo, a brevidade da expressão lambda pode ser atribuída a vários fatores. Primeiro: a palavra-chave delegate não é usada para introduzir a construção. Em vez disso, há um novo operador, =>, que informa ao compilador que esta não é uma expressão normal. Segundo: o tipo Customer é inferido do uso. Nesse caso, a assinatura do método Where tem a seguinte aparência:
public static IEnumerable<T> Where<T>(
IEnumerable<T> items, Func<T, bool> predicate)
O compilador é capaz de inferir que "c" se refere a um cliente porque o primeiro parâmetro do método Where é IEnumerable<Customer>, de tal forma que T deve ser, na verdade, Customer. Usando essa informação, o compilador também verifica que Customer tem um membro ZipCode. O último fator é que não há nenhuma palavra-chave return especificada. Na forma sintática, o membro return é omitido, mas por pura conveniência sintática. O resultado da expressão ainda é considerado como sendo o valor return.
As expressões lambda, assim como os métodos anônimos, também oferecem suporte à captura de variáveis. Por exemplo, é possível fazer referência aos parâmetros ou locais do método que contém a expressão lambda dentro do corpo da expressão:
public IEnumerable<Customer> LocalCusts(
IEnumerable<Customer> customers, int zipCode)
{
return EnumerableExtensions.Where(customers,
c => c.ZipCode == zipCode);
}
Finalmente, as expressões lambda oferecem suporte a uma sintaxe mais detalhada que permite especificar os tipos explicitamente e executar várias instruções. Por exemplo:
return EnumerableExtensions.Where(customers,
(Customer c) => { int zip = zipCode; return c.ZipCode == zip; });
A boa notícia é que estamos muito mais perto da sintaxe ideal proposta no documento original e podemos chegar lá com um recurso de linguagem que é geralmente útil fora dos operadores de consulta. Vamos rever onde estamos:
IEnumerable<Customer> locals =
EnumerableExtensions.Where(customers, c => c.ZipCode == 91822);
Há um problema óbvio aqui. Em vez de pensar nas operações que podem ser executadas em Customer, o consumidor precisa obter informações sobre essa classe EnumerableExtensions. Além disso, no caso de vários operadores, o consumidor precisa inverter o pensamento para escrever a sintaxe correta. Por exemplo:
IEnumerable<string> locals =
EnumerableExtensions.Select(
EnumerableExtensions.Where(customers, c => c.ZipCode == 91822),
c => c.Name);
Observe que Select é o método externo, embora opere no resultado do método Where. A sintaxe ideal teria uma aparência mais semelhante a esta:
sequence<Customer> locals =
customers.where(ZipCode == 98112).select(Name);
Assim, seria possível nos aproximarmos mais da sintaxe ideal com outro recurso de linguagem?
Métodos de extensão
Uma sintaxe muito melhor acabou surgindo na forma de um recurso de linguagem conhecido como métodos de extensão. Basicamente, são métodos estáticos que podem ser chamados através de uma sintaxe de instância. A origem do problema da consulta acima é que queremos adicionar métodos a IEnumerable<T>. Entretanto, se tivéssemos de adicionar operadores (como Where, Select, etc.), cada implementador atual ou futuro precisaria implementar esses métodos. Todavia, a vasta maioria dessas implementações seria a mesma. A única maneira de compartilhar "implementação de interface" no C# é usar métodos estáticos, que foi o que fizemos com a classe EnumerableExtensions.
Em vez disso, vamos supor que precisássemos escrever o método Where como um método de extensão. A consulta poderia ser reescrita da seguinte forma:
IEnumerable<Customer> locals =
customers.Where(c => c.ZipCode == 91822);
Para essa consulta simples, essa sintaxe está muito próxima da ideal. Mas o que significa exatamente escrever o método Where como um método de extensão? Na realidade, é bem simples. Basicamente, a assinatura do método estático é alterada de forma que o modificador "this" seja adicionado ao primeiro parâmetro:
public static IEnumerable<T> Where<T>(
this IEnumerable<T> items, Func<T, bool> predicate)
Além disso, o método deve ser declarado dentro de uma classe estática. A classe estática é aquela que pode conter apenas membros estáticos e que é indicada pelo modificador estático na declaração de classe. E isso é tudo! Essa declaração instrui o compilador a permitir que Where seja chamado com a mesma sintaxe que um método de instância em qualquer tipo que implemente IEnumerable<T>. Contudo, é necessário que o método Where possa ser acessado do escopo atual. Um método está no escopo quando o tipo que o contém também está. Portanto, é possível trazer métodos de extensão para o escopo através da diretiva Using. (Consulte a barra lateral "Métodos de extensão" para obter mais informações.)
Temos agora uma sintaxe que está muito próxima da ideal para a cláusula de filtro, mas isso é tudo que há na versão "Orcas" do C#? Não exatamente. Vamos ampliar o exemplo um pouco projetando somente o nome do cliente, e não o objeto inteiro. Como mencionei anteriormente, a sintaxe ideal teria a seguinte forma:
sequence<string> locals =
customers.where(ZipCode == 98112).select(Name);
Apenas com as extensões de linguagem abordadas, as expressões lambda e os métodos de extensão, isso poderia ser reescrito da seguinte forma:
IEnumerable<string> locals =
customers.Where(c => c.ZipCode == 91822).Select(c => c.Name);
Observe que o tipo de retorno é diferente para esta consulta — IEnumerable<string> em vez de IEnumerable<Customer>. Isso ocorre porque estamos retornando somente o nome do cliente da instrução SELECT.
Isso funciona perfeitamente bem quando a projeção é apenas um único campo. No entanto, suponha que queremos retornar também o endereço do cliente, e não apenas o nome dele. A sintaxe ideal poderia ter esta aparência:
locals = customers.where(ZipCode == 98112).select(Name, Address);
Tipos anônimos
Se precisássemos continuar usando a nossa sintaxe já existente para retornar o nome e o endereço, rapidamente nos defrontaríamos com o problema de que não há nenhum tipo que contenha apenas um Name e um Address. Entretanto, ainda seria possível escrever essa consulta introduzindo este tipo:
class CustomerTuple
{
public string Name;
public string Address;
public CustomerTuple(string name, string address)
{
this.Name = name;
this.Address = address;
}
}
Poderíamos então usar esse tipo (aqui CustomerTuple) para construir o resultado da nossa consulta:
IEnumerable<CustomerTuple> locals =
customers.Where(c => c.ZipCode == 91822)
.Select(c => new CustomerTuple(c.Name, c.Address));
Com certeza, isso parece muito código clichê para projetar um subconjunto dos campos. Raramente está claro também qual é o nome desse tipo. CustomerTuple é realmente um bom nome? E se tivéssemos projetado Name e Age em vez disso? Também poderia ser um CustomerTuple. Então, nossos problemas residem no fato de termos código clichê e, aparentemente, não haver bons nomes para os tipos que criamos. Além disso, também poderia haver muitos tipos diferentes obrigatórios. O gerenciamento deles poderia se tornar uma dor de cabeça rapidamente.
É exatamente para isso que servem os tipos anônimos. Basicamente, esse recurso permite a criação de tipos estruturais sem especificar o nome. Se reescrevermos a consulta acima usando tipos anônimos, este será o resultado:
locals = customers.Where(c => c.ZipCode == 91822)
.Select(c => new { c.Name, c.Address });
Esse código cria implicitamente um tipo com os campos Name e Address:
class
{
public string Name;
public string Address;
}
Esse tipo não pode ser referenciado pelo nome, já que não tem nenhum. Os nomes dos campos podem ser declarados explicitamente na criação de tipos anônimos. Por exemplo, é possível alterar o nome caso o campo que está sendo criado derive de uma expressão complicada ou simplesmente o nome não seja o desejado:
locals = customers.Where(c => c.ZipCode == 91822)
.Select(c => new { FullName = c.FirstName + “ “ + c.LastName,
HomeAddress = c.Address });
Nesse caso, o tipo gerado possui campos chamados FullName e HomeAddress.
Isso chega mais perto do ideal, mas há um problema. Você observará que, estrategicamente, eu omiti o tipo de local em qualquer lugar no qual usei um tipo anônimo. Obviamente, se não podemos determinar o nome de tipos anônimos, como os usamos?
Variáveis de local digitadas implicitamente
Existe outro recurso de linguagem conhecido como variáveis de local digitadas implicitamente (ou var, de forma abreviada) que instrui o compilador a inferir o tipo de uma variável de local. Por exemplo:
Nesse caso, o inteiro tem o tipo int. É importante entender que isso ainda é com rigidez de tipos. Em linguagem dinâmica, o tipo do inteiro poderia ser alterado posteriormente. Para ilustrar isso, o código a seguir não está compilado:
var integer = 1;
integer = “hello”;
O compilador C# reportará um erro na segunda linha, informando que não pode converter implicitamente uma cadeira de caracteres em um int.
No caso da consulta acima, agora podemos escrever a atribuição completa, conforme mostrado aqui:
var locals =
customers
.Where(c => c.ZipCode == 91822)
.Select(c => new { FullName = c.FirstName + “ “ + c.LastName,
HomeAddress = c.Address });
O tipo de local acaba sendo IEnumerable<?>, onde "?" é o nome de um tipo que não pode ser escrito (já que é anônimo).
Locais digitados implicitamente são apenas locais dentro de um método. Eles não podem sair dos limites de um método, propriedade, indexador ou outro bloco porque o tipo não pode ser declarado explicitamente e "var" não é válido para campos ou tipos de parâmetro.
Locais digitados implicitamente acabam sendo convenientes fora do contexto de uma consulta. Eles ajudam, por exemplo, a simplificar instanciações genéricas complicadas:
var customerListLookup = new Dictionary<string, List<Customer>>();
Nossa posição agora em relação à consulta é boa: estamos perto da sintaxe ideal e chegamos lá com recursos de linguagem de uso geral.
Descobrimos que, conforme mais pessoas trabalhavam com essa sintaxe, freqüentemente era necessário permitir que uma projeção saísse dos limites de um método. Como vimos anteriormente, isso é possível por meio da construção de um objeto chamando seu construtor a partir de Select. No entanto, o que acontece se não houver nenhum construtor que extraia exatamente os valores que precisam ser definidos?
Inicializadores de objeto
Para esse caso, existe um recurso de linguagem na futura versão "Orcas" conhecido como inicializadores de objeto. Basicamente, eles permitem a atribuição de várias propriedades ou campos em uma única expressão. Por exemplo, um padrão comum para a criação de objeto é:
Customer customer = new Customer();
customer.Name = “Roger”;
customer.Address = “1 Wilco Way”;
Nesse caso, não há nenhum construtor de Customer que extraia um nome e um endereço. Entretanto, existem duas propriedades, Name e Address, que poderão ser definidas assim que uma instância for criada. Os inicializadores de objeto permitem a mesma criação com a seguinte sintaxe:
Customer customer = new Customer()
{ Name = “Roger”, Address = “1 Wilco Way” };
Em nosso exemplo CustomerTuple anterior, criamos a classe CustomerTuple chamando seu construtor. Podemos conseguir o mesmo resultado através dos inicializadores de objeto:
var locals =
customers
.Where(c => c.ZipCode == 91822)
.Select(c =>
new CustomerTuple { Name = c.Name, Address = c.Address });
Observe que os inicializadores de objeto permitem que os parênteses do construtor sejam omitidos. Além disso, os dois campos e as propriedades configuráveis podem ser atribuídos dentro do corpo do inicializador de objeto.
Agora temos uma sintaxe sucinta para a criação de consultas no C#. No entanto, também temos um meio extensível de adicionar novos operadores (Distinct, OrderBy, Sum, etc.) através de métodos de extensão e um conjunto distinto de recursos de linguagem úteis por si mesmos.
A equipe de design de linguagem agora tem diversos protótipos sobre os quais deverá receber comentários. Organizamos então um estudo de usabilidade com participantes que tinham experiência tanto em C# como em SQL. Os comentários foram quase todos positivos, mas ficou claro que algo estava faltando. Em particular, foi difícil para os desenvolvedores aplicarem seu conhecimento de SQL porque a sintaxe que acreditávamos ser a ideal não se enquadrou muito bem com a experiência de domínio deles.
Expressões de consulta
A equipe de design de linguagem então projetou uma sintaxe mais próxima do SQL, conhecida como expressões de consulta. Uma expressão de consulta para o nosso exemplo teria esta aparência:
var locals = from c in customers
where c.ZipCode == 91822
select new { FullName = c.FirstName + “ “ +
c.LastName, HomeAddress = c.Address };
As expressões de consulta baseiam-se nos recursos de linguagem descritos acima e são convertidas sintaticamente na sintaxe subjacente que já vimos. Por exemplo, a consulta acima é convertida diretamente em:
var locals =
customers
.Where(c => c.ZipCode == 91822)
.Select(c => new { FullName = c.FirstName + “ “ + c.LastName,
HomeAddress = c.Address });
As expressões de consulta oferecem suporte a várias "cláusulas" diferentes , como where, select, orderby, group by, let e join. Essas cláusulas se convertem nas chamadas de operador equivalentes, que, por sua vez, são implementadas através de métodos de extensão. A estreita relação entre as cláusulas de consulta e os métodos de extensão que implementam os operadores facilita a combinação deles no caso de a sintaxe da consulta não oferecer suporte a uma cláusula para um operador necessário. Por exemplo:
var locals = (from c in customers
where c.ZipCode == 91822
select new { FullName = c.FirstName + “ “ +
c.LastName, HomeAddress = c.Address})
.Count();
Nesse caso, a consulta retorna o número de clientes que vivem na área onde o CEP é 91822.
Com isso, conseguimos terminar exatamente onde começamos (o que eu sempre acho bem satisfatório). A sintaxe da próxima versão do C# evoluiu nos últimos anos através de vários recursos de linguagem novos para finalmente chegar muito perto da sintaxe original proposta no inverno de 2004. A adição de expressões de consulta se baseia nos fundamentos fornecidos pelos outros recursos de linguagem na futura versão de C# e facilita a leitura e a compreensão de muitos cenários de consulta para desenvolvedores com experiência em SQL.
Anson Horton é gerente de programa da Microsoft há quase seis anos. Membro da equipe do C# desde a sua criação, ele integrou a equipe do C++ antes disso. Ele está envolvido no design do compilador, da linguagem, do sistema de projeto, do IDE (IntelliSense), do depurador e do avaliador de expressões do C#. Anson mantém um blog em
blogs.msdn.com/ansonh que ele atualiza raramente.