O sistema do tipo C#

C# é uma linguagem fortemente tipada. Todas as variáveis e constantes têm um tipo, assim como cada expressão que é avaliada como um valor. Cada declaração de método especifica um nome, o tipo e a variante (valor, referência ou saída) de cada parâmetro de entrada e do valor retornado. A biblioteca de classes do .NET define tipos numéricos internos e tipos complexos que representam uma grande variedade de constructos. Isso inclui o sistema de arquivos, conexões de rede, coleções e matrizes de objetos e datas. Um programa em C# típico usa tipos da biblioteca de classes e tipos definidos pelo usuário que modelam os conceitos que são específicos para o domínio do problema do programa.

As informações armazenadas em um tipo podem incluir os seguintes itens:

  • O espaço de armazenamento que uma variável do tipo requer.
  • Os valores mínimo e máximo que ele pode representar.
  • Os membros (métodos, campos, eventos e etc.) que ele contém.
  • O tipo base do qual ele herda.
  • A interface implementada.
  • Os tipos de operações que são permitidos.

O compilador usa as informações de tipo para garantir que todas as operações que são realizadas em seu código sejam fortemente tipadas. Por exemplo, se você declarar uma variável do tipo int, o compilador permitirá que você use a variável nas operações de adição e subtração. Se você tentar executar as mesmas operações em uma variável do tipo bool, o compilador gerará um erro, como mostrado no exemplo a seguir:

int a = 5;
int b = a + 2; //OK

bool test = true;

// Error. Operator '+' cannot be applied to operands of type 'int' and 'bool'.
int c = a + test;

Observação

Desenvolvedores de C e C++, observem que, em C#, bool não é conversível em int.

O compilador insere as informações de tipo no arquivo executável como metadados. O CLR (Common Language Runtime) usa esses metadados em tempo de execução para assegurar mais segurança de tipos ao alocar e recuperar a memória.

Especificando tipos em declarações de variável

Quando declara uma variável ou constante em um programa, você deve especificar seu tipo ou usar a palavra-chave var para permitir que o compilador infira o tipo. O exemplo a seguir mostra algumas declarações de variáveis que usam tipos numéricos internos e tipos complexos definidos pelo usuário:

// Declaration only:
float temperature;
string name;
MyClass myClass;

// Declaration with initializers (four examples):
char firstLetter = 'C';
var limit = 3;
int[] source = { 0, 1, 2, 3, 4, 5 };
var query = from item in source
            where item <= limit
            select item;

Os tipos de parâmetros de método e valores de retorno são especificados na declaração do método. A assinatura a seguir mostra um método que requer um int como um argumento de entrada e retorna uma cadeia de caracteres:

public string GetName(int ID)
{
    if (ID < names.Length)
        return names[ID];
    else
        return String.Empty;
}
private string[] names = { "Spencer", "Sally", "Doug" };

Depois de declarar uma variável, não será possível reenviá-la com um novo tipo, nem atribuir um valor incompatível com seu tipo declarado. Por exemplo, você não pode declarar um int e, em seguida, atribuir a ele um valor booliano de true. No entanto, os valores podem ser convertidos em outros tipos, por exemplo, quando são passados como argumentos de método ou atribuídos a novas variáveis. Uma conversão de tipo que não causa a perda de dados é executada automaticamente pelo compilador. Uma conversão que pode causar perda de dados requer um cast no código-fonte.

Para obter mais informações, consulte Conversões Cast e Conversões de Tipo.

Tipos internos

O C# fornece um conjunto padrão de tipos internos. Eles representam números inteiros, valores de ponto flutuante, expressões boolianas, caracteres de texto, valores decimais e outros tipos de dados. Também há tipos string e object internos. Esses tipos estão disponíveis para uso em qualquer programa em C#. Para obter a lista completa tipos internos, consulte Tipos internos.

Tipos personalizados

Você usa os constructos struct, class, interface, enum e record para criar seus próprios tipos personalizados. A biblioteca de classes do .NET em si é uma coleção de tipos personalizados que você pode usar em seus próprios aplicativos. Por padrão, os tipos usados com mais frequência na biblioteca de classes estão disponíveis em qualquer programa em C#. Outros ficam disponíveis somente quando você adiciona explicitamente uma referência de projeto ao assembly que os define. Depois que o compilador tiver uma referência ao assembly, você pode declarar variáveis (e constantes) dos tipos declarados nesse assembly no código-fonte. Para saber mais, confira Biblioteca de classes do .NET.

O Common Type System

É importante entender os dois pontos fundamentais sobre o sistema de tipos do .NET:

  • Ele dá suporte ao conceito de herança. Os tipos podem derivar de outros tipos, chamados tipos base. O tipo derivado herda (com algumas restrições) os métodos, as propriedades e outros membros do tipo base. O tipo base, por sua vez, pode derivar de algum outro tipo, nesse caso, o tipo derivado herda os membros de ambos os tipos base na sua hierarquia de herança. Todos os tipos, incluindo tipos numéricos internos, como o System.Int32 (palavra-chave do C#: int), derivam, em última análise, de um único tipo base, que é o System.Object (palavra-chave do C#: object). Essa hierarquia unificada de tipos é chamada de CTS (Common Type System). Para obter mais informações sobre herança em C#, consulte Herança.
  • Cada tipo no CTS é definido como um tipo de valor ou um tipo de referência. Esses tipos incluem todos os tipos personalizados na biblioteca de classes do .NET, além de tipos personalizados definidos pelo usuário. Os tipos que você define usando a palavra-chave struct são tipos de valor. Todos os tipos numéricos internos são structs. Os tipos que você define usando a palavra-chave class ou record são tipos de referência. Os tipos de referência e os tipos de valor têm diferentes regras de tempo de compilação e comportamento de tempo de execução diferente.

A ilustração a seguir mostra a relação entre tipos de referência e tipos de valor no CTS.

Captura de tela que mostra de tipos de valor CTS e tipos de referência.

Observação

Você pode ver que os tipos mais usados normalmente são todos organizados no namespace System. No entanto, o namespace no qual um tipo está contido não tem relação com a possibilidade de ele ser um tipo de valor ou um tipo de referência.

Classes e structs são duas das construções básicas do Common Type System no .NET. O C# 9 adiciona registros, que são um tipo de classe. Cada um é, essencialmente, uma estrutura de dados que encapsula um conjunto de dados e os comportamentos que são uma unidade lógica. Os dados e comportamentos são os membros da classe, struct ou registro. Os membros incluem seus métodos, propriedades, eventos e assim por diante, conforme listado posteriormente neste artigo.

Uma declaração de classe, struct ou registro é como um plano que é usado para criar instâncias ou objetos em tempo de execução. Se você definir uma classe, struct ou registro denominado Person, Person será o nome do tipo. Se você declarar e inicializar um p variável do tipo Person, p será considerado um objeto ou uma instância de Person. Várias instâncias do mesmo tipo Person podem ser criadas, e cada instância pode ter valores diferentes em suas propriedades e campos.

Uma classe é um tipo de referência. Quando um objeto do tipo é criado, a variável à qual o objeto é atribuído armazena apenas uma referência na memória. Quando a referência de objeto é atribuída a uma nova variável, a nova variável refere-se ao objeto original. As alterações feitas por meio de uma variável são refletidas na outra variável porque ambas se referem aos mesmos dados.

Um struct é um tipo de valor. Quando um struct é criado, a variável à qual o struct está atribuído contém os dados reais do struct. Quando o struct é atribuído a uma nova variável, ele é copiado. A nova variável e a variável original, portanto, contêm duas cópias separadas dos mesmos dados. As alterações feitas em uma cópia não afetam a outra cópia.

Os tipos de registro podem ser tipos de referência (record class) ou tipos de valor (record struct).

Em geral, as classes são usadas para modelar um comportamento mais complexo. As classes normalmente armazenam dados que devem ser modificados depois que um objeto de classe é criado. Structs são mais adequados para estruturas de dados pequenas. Os structs normalmente armazenam dados que não se destinam a serem modificados após a criação do struct. Os tipos de registro são estruturas de dados com membros sintetizados de compilador adicionais. Os registros normalmente armazenam dados que não se destinam a serem modificados após a criação do objeto.

Tipos de valor

Os tipos de valor derivam de System.ValueType, que deriva de System.Object. Os tipos que derivam de System.ValueType apresentam um comportamento especial no CLR. As variáveis de tipo de valor contêm diretamente seus valores. A memória de um struct é embutida em qualquer contexto em que a variável seja declarada. Não há nenhuma alocação de heap separada ou sobrecarga de coleta de lixo para variáveis do tipo de valor. É possível declarar tipos record struct que são tipos de valor e incluir os membros sintetizados para registros.

Há duas categorias de tipos de valor: struct e enum.

Os tipos numéricos internos são structs e têm campos e métodos que você pode acessar:

// constant field on type byte.
byte b = byte.MaxValue;

Mas você declara e atribui valores a eles como se fossem tipos de não agregação simples:

byte num = 0xA;
int i = 5;
char c = 'Z';

Os tipos de valor são selados. Não é possível derivar um tipo de qualquer tipo de valor, por exemplo System.Int32. Você não pode definir um struct a ser herdado de qualquer classe definida pelo usuário ou struct, porque um struct só pode ser herdado de System.ValueType. No entanto, um struct pode implementar uma ou mais interfaces. É possível converter um tipo struct em qualquer tipo de interface que ele implementa. Essa conversão faz com que uma operação de conversão boxing encapsule o struct dentro de um objeto de tipo de referência no heap gerenciado. As operações de conversão boxing ocorrem quando você passa um tipo de valor para um método que usa um System.Object ou qualquer tipo de interface como parâmetro de entrada. Para obter mais informações, consulte Conversões boxing e unboxing.

Você usa a palavra-chave struct para criar seus próprios tipos de valor personalizados. Normalmente, um struct é usado como um contêiner para um pequeno conjunto de variáveis relacionadas, conforme mostrado no exemplo a seguir:

public struct Coords
{
    public int x, y;

    public Coords(int p1, int p2)
    {
        x = p1;
        y = p2;
    }
}

Para obter mais informações sobre structs, consulte Tipos de estrutura. Para saber mais sobre os tipos de valor, confira Tipos de valor.

A outra categoria de tipos de valor é enum. Uma enum define um conjunto de constantes integrais nomeadas. Por exemplo, a enumeração System.IO.FileMode na biblioteca de classes do .NET contém um conjunto de números inteiros constantes nomeados que especificam como um arquivo deve ser aberto. Ela é definida conforme mostrado no exemplo abaixo:

public enum FileMode
{
    CreateNew = 1,
    Create = 2,
    Open = 3,
    OpenOrCreate = 4,
    Truncate = 5,
    Append = 6,
}

A constante System.IO.FileMode.Create tem um valor de 2. No entanto, o nome é muito mais significativo para a leitura do código-fonte por humanos e, por esse motivo, é melhor usar enumerações em vez de números literais constantes. Para obter mais informações, consulte System.IO.FileMode.

Todas as enumerações herdam de System.Enum, que herda de System.ValueType. Todas as regras que se aplicam a structs também se aplicam a enums. Para obter mais informações sobre enums, consulte Tipos de enumeração.

Tipos de referência

Um tipo que é definido como class, record, delegate, matriz ou interface é um reference type.

Ao declarar uma variável de um reference type, ele contém o valor null até que você o atribua com uma instância desse tipo ou crie uma usando o operador new. A criação e a atribuição de uma classe são demonstradas no exemplo a seguir:

MyClass myClass = new MyClass();
MyClass myClass2 = myClass;

Não é possível criar uma instância direta de interface usando o operador new. Em vez disso, crie e atribua uma instância de uma classe que implemente a interface. Considere o seguinte exemplo:

MyClass myClass = new MyClass();

// Declare and assign using an existing value.
IMyInterface myInterface = myClass;

// Or create and assign a value in a single statement.
IMyInterface myInterface2 = new MyClass();

Quando o objeto é criado, a memória é alocada no heap gerenciado. A variável contém apenas uma referência ao local do objeto. Os tipos no heap gerenciado exigem sobrecarga quando são alocados e recuperados. A coleta de lixo é a funcionalidade de gerenciamento automático de memória do CLR, que executa a recuperação. No entanto, a coleta de lixo também é altamente otimizada e, na maioria dos cenários, não cria um problema de desempenho. Para obter mais informações sobre a coleta de lixo, consulte Gerenciamento automático de memória.

Todas as matrizes são tipos de referência, mesmo se seus elementos forem tipos de valor. As matrizes derivam implicitamente da classe System.Array. Você declara e usa as matrizes com a sintaxe simplificada fornecida pelo C#, conforme mostrado no exemplo a seguir:

// Declare and initialize an array of integers.
int[] nums = { 1, 2, 3, 4, 5 };

// Access an instance property of System.Array.
int len = nums.Length;

Os tipos de referência dão suporte completo à herança. Quando você cria uma classe, é possível herdar de qualquer outra interface ou classe que não esteja definida como selada. Outras classes podem herdar de sua classe e substituir seus métodos virtuais. Para obter mais informações sobre como criar suas próprias classes, consulte Classes, structs e registros. Para obter mais informações sobre herança e métodos virtuais, consulte Herança.

Tipos de valores literais

No C#, valores literais recebem um tipo do compilador. Você pode especificar como um literal numérico deve ser digitado anexando uma letra ao final do número. Por exemplo, para especificar que o valor 4.56 deve ser tratado como um float, acrescente um "f" ou "F" após o número: 4.56f. Se nenhuma letra for anexada, o compilador inferirá um tipo para o literal. Para obter mais informações sobre quais tipos podem ser especificados com sufixos de letra, consulte Tipos numéricos integrais e Tipos numéricos de ponto flutuante.

Como os literais são tipados e todos os tipos derivam basicamente de System.Object, você pode escrever e compilar o código como o seguinte:

string s = "The answer is " + 5.ToString();
// Outputs: "The answer is 5"
Console.WriteLine(s);

Type type = 12345.GetType();
// Outputs: "System.Int32"
Console.WriteLine(type);

Tipos genéricos

Um tipo pode ser declarado com um ou mais parâmetros de tipo que servem como um espaço reservado para o tipo real (o tipo concreto). O código do cliente fornece o tipo concreto quando ele cria uma instância do tipo. Esses tipos são chamados de tipos genéricos. Por exemplo, o tipo do .NET System.Collections.Generic.List<T> tem um parâmetro de tipo que, por convenção, recebe o nome T. Ao criar uma instância do tipo, você pode especificar o tipo dos objetos que a lista conterá, por exemplo, string:

List<string> stringList = new List<string>();
stringList.Add("String example");
// compile time error adding a type other than a string:
stringList.Add(4);

O uso do parâmetro de tipo possibilita a reutilização da mesma classe para conter qualquer tipo de elemento sem precisar converter cada elemento em objeto. As classes de coleção genéricas são chamadas de coleções fortemente tipadas porque o compilador sabe o tipo específico dos elementos da coleção e pode gerar um erro em tempo de compilação se, por exemplo, você tentar adicionar um inteiro ao objeto stringList no exemplo anterior. Para obter mais informações, consulte Genéricos.

Tipos implícitos, tipos anônimos e tipos que permitem valor nulo

Você pode digitar implicitamente uma variável local (mas não os membros de classe) usando a palavra-chave var. A variável ainda recebe um tipo em tempo de compilação, mas o tipo é fornecido pelo compilador. Para obter mais informações, consulte Variáveis locais de tipo implícito.

Pode ser inconveniente criar um tipo nomeado para conjuntos simples de valores relacionados que você não pretende armazenar ou transmitir fora dos limites de método. Você pode criar tipos anônimos para essa finalidade. Para obter mais informações, consulte Tipos Anônimos.

Os tipos comuns de valor não podem ter um valor null. No entanto, você pode criar tipos de valor anulável acrescentando uma ? após o tipo. Por exemplo, int? é um tipo int que também pode ter o valor null. Os tipos que permitem valor nulo são instâncias do tipo struct genérico System.Nullable<T>. Os tipos que permitem valor nulo são especialmente úteis quando você está passando dados entre bancos de dados nos quais os valores numéricos podem ser null. Para obter mais informações, consulte Tipos que permitem valor nulo.

Tipo de tempo de compilação e tipo de tempo de execução

Uma variável pode ter diferentes tipos de tempo de compilação e tempo de execução. O tipo de tempo de compilação é o tipo declarado ou inferido da variável no código-fonte. O tipo de tempo de execução é o tipo da instância referenciada por essa variável. Geralmente, esses dois tipos são iguais, como no exemplo a seguir:

string message = "This is a string of characters";

Em outros casos, o tipo de tempo de compilação é diferente, conforme mostrado nos dois exemplos a seguir:

object anotherMessage = "This is another string of characters";
IEnumerable<char> someCharacters = "abcdefghijklmnopqrstuvwxyz";

Em ambos os exemplos acima, o tipo de tempo de execução é um string. O tipo de tempo de compilação é object na primeira linha e IEnumerable<char> na segunda.

Se os dois tipos forem diferentes para uma variável, é importante entender quando o tipo de tempo de compilação e o tipo de tempo de execução se aplicam. O tipo de tempo de compilação determina todas as ações executadas pelo compilador. Essas ações do compilador incluem resolução de chamada de método, resolução de sobrecarga e conversões implícitas e explícitas disponíveis. O tipo de tempo de execução determina todas as ações que são resolvidas em tempo de execução. Essas ações de tempo de execução incluem a expedição de chamadas de método virtual, avaliação das expressões is e switch e outras APIs de teste de tipo. Para entender melhor como seu código interage com tipos, reconheça qual ação se aplica a qual tipo.

Para obter mais informações, consulte os seguintes artigos:

Especificação da linguagem C#

Para obter mais informações, consulte a Especificação da linguagem C#. A especificação da linguagem é a fonte definitiva para a sintaxe e o uso de C#.