El sistema de tipos de C#

C# es un lenguaje fuertemente tipado. Todas las variables y constantes tienen un tipo, al igual que todas las expresiones que se evalúan como un valor. Cada declaración del método especifica un nombre, el tipo y naturaleza (valor, referencia o salida) para cada parámetro de entrada y para el valor devuelto. La biblioteca de clases .NET define tipos numéricos integrados, así como tipos complejos que representan una amplia variedad de construcciones. Entre ellas se incluyen el sistema de archivos, conexiones de red, colecciones y matrices de objetos, y fechas. Los programas de C# típicos usan tipos de la biblioteca de clases, así como tipos definidos por el usuario que modelan los conceptos que son específicos del dominio del problema del programa.

Entre la información almacenada en un tipo se pueden incluir los siguientes elementos:

  • El espacio de almacenamiento que requiere una variable del tipo.
  • Los valores máximo y mínimo que puede representar.
  • Los miembros (métodos, campos, eventos, etc.) que contiene.
  • El tipo base del que hereda.
  • Interfaces que implementa.
  • Los tipos de operaciones permitidas.

El compilador usa información de tipo para garantizar que todas las operaciones que se realizan en el código cuentan con seguridad de tipos. Por ejemplo, si declara una variable de tipo int, el compilador le permite usar la variable en operaciones de suma y resta. Si intenta realizar esas mismas operaciones en una variable de tipo bool, el compilador genera un error, como se muestra en el siguiente ejemplo:

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;

Nota

Los desarrolladores de C y C++ deben tener en cuenta que, en C#, bool no se puede convertir en int.

El compilador inserta la información de tipo en el archivo ejecutable como metadatos. Common Language Runtime (CLR) usa esos metadatos en tiempo de ejecución para garantizar aún más la seguridad de tipos cuando asigna y reclama memoria.

Especificar tipos en declaraciones de variable

Cuando declare una variable o constante en un programa, debe especificar su tipo o utilizar la palabra clave var para que el compilador infiera el tipo. En el ejemplo siguiente se muestran algunas declaraciones de variable que utilizan tanto tipos numéricos integrados como tipos complejos definidos por el usuario:

// 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;

Los tipos de parámetros del método y los valores devueltos se especifican en la declaración del método. En la siguiente firma se muestra un método que requiere una variable int como argumento de entrada y devuelve una cadena:

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

Después de declarar una variable, no se puede volver a declarar con un nuevo tipo y no se puede asignar un valor que no sea compatible con su tipo declarado. Por ejemplo, no puede declarar un valor int y, luego, asignarle un valor booleano de true. En cambio, los valores se pueden convertir en otros tipos, por ejemplo, cuando se asignan a variables nuevas o se pasan como argumentos de método. El compilador realiza automáticamente una conversión de tipo que no da lugar a una pérdida de datos. Una conversión que pueda dar lugar a la pérdida de datos requiere un valor cast en el código fuente.

Para obtener más información, vea Conversiones de tipos.

Tipos integrados

C# proporciona un conjunto estándar de tipos integrados. Estos representan números enteros, valores de punto flotante, expresiones booleanas, caracteres de texto, valores decimales y otros tipos de datos. También hay tipos string y object integrados. Estos tipos están disponibles para su uso en cualquier programa de C#. Para obtener una lista completa de los tipos integrados, vea Tipos integrados.

Tipos personalizados

Puede usar las construcciones struct, class, interface, enum y record para crear sus propios tipos personalizados. La biblioteca de clases .NET es en sí misma una colección de tipos personalizados que puede usar en sus propias aplicaciones. De forma predeterminada, los tipos usados con más frecuencia en la biblioteca de clases están disponibles en cualquier programa de C#. Otros están disponibles solo cuando agrega explícitamente una referencia de proyecto al ensamblado que los define. Una vez que el compilador tenga una referencia al ensamblado, puede declarar variables (y constantes) de los tipos declarados en dicho ensamblado en el código fuente. Para más información, vea Biblioteca de clases .NET.

Common Type System

Es importante entender dos aspectos fundamentales sobre el sistema de tipos en .NET:

  • Es compatible con el principio de herencia. Los tipos pueden derivarse de otros tipos, denominados tipos base. El tipo derivado hereda (con algunas restricciones), los métodos, las propiedades y otros miembros del tipo base. A su vez, el tipo base puede derivarse de algún otro tipo, en cuyo caso el tipo derivado hereda los miembros de ambos tipos base en su jerarquía de herencia. Todos los tipos, incluidos los tipos numéricos integrados como System.Int32 (palabra clave de C#: int), se derivan en última instancia de un único tipo base, que es System.Object (palabra clave de C#: object). Esta jerarquía de tipos unificada se denomina Common Type System (CTS). Para más información sobre la herencia en C#, vea Herencia.
  • En CTS, cada tipo se define como un tipo de valor o un tipo de referencia. Estos tipos incluyen todos los tipos personalizados de la biblioteca de clases .NET y también sus propios tipos definidos por el usuario. Los tipos que se definen mediante el uso de la palabra clave struct son tipos de valor; todos los tipos numéricos integrados son structs. Los tipos que se definen mediante el uso de la palabra clave class o record son tipos de referencia. Los tipos de referencia y los tipos de valor tienen distintas reglas de tiempo de compilación y distintos comportamientos de tiempo de ejecución.

En la ilustración siguiente se muestra la relación entre los tipos de valor y los tipos de referencia en CTS.

Captura de pantalla en la que se muestran tipos de valores y tipos de referencias en CTS.

Nota

Puede ver que los tipos utilizados con mayor frecuencia están organizados en el espacio de nombres System. Sin embargo, el espacio de nombres que contiene un tipo no tiene ninguna relación con un tipo de valor o un tipo de referencia.

Las clases (class) y estructuras (struct) son dos de las construcciones básicas de Common Type System en .NET. C# 9 agrega registros, que son un tipo de clase. Cada una de ellas es básicamente una estructura de datos que encapsula un conjunto de datos y comportamientos que forman un conjunto como una unidad lógica. Los datos y comportamientos son los miembros de la clase, estructura o registro. Los miembros incluyen sus métodos, propiedades y eventos, entre otros elementos, como se muestra más adelante en este artículo.

Una declaración de clase, estructura o registro es como un plano que se utiliza para crear instancias u objetos en tiempo de ejecución. Si define una clase, una estructura o un registro denominado Person, Person es el nombre del tipo. Si declara e inicializa una variable p de tipo Person, se dice que p es un objeto o instancia de Person. Se pueden crear varias instancias del mismo tipo Person, y cada instancia tiene diferentes valores en sus propiedades y campos.

Una clase es un tipo de referencia. Cuando se crea un objeto del tipo, la variable a la que se asigna el objeto contiene solo una referencia a esa memoria. Cuando la referencia de objeto se asigna a una nueva variable, la nueva variable hace referencia al objeto original. Los cambios realizados en una variable se reflejan en la otra variable porque ambas hacen referencia a los mismos datos.

Una estructura es un tipo de valor. Cuando se crea una estructura, la variable a la que se asigna la estructura contiene los datos reales de ella. Cuando la estructura se asigna a una nueva variable, se copia. Por lo tanto, la nueva variable y la variable original contienen dos copias independientes de los mismos datos. Los cambios realizados en una copia no afectan a la otra copia.

Los tipos de registro pueden ser tipos de referencia (record class) o tipos de valor (record struct).

En general, las clases se utilizan para modelar comportamientos más complejos. Las clases suelen almacenar datos que están diseñados para modificarse después de crear un objeto de clase. Los structs son más adecuados para estructuras de datos pequeñas. Los structs suelen almacenar datos que no están diseñados para modificarse después de que se haya creado el struct. Los tipos de registro son estructuras de datos con miembros sintetizados del compilador adicionales. Los registros suelen almacenar datos que no están diseñados para modificarse después de que se haya creado el objeto.

Tipos de valor

Los tipos de valor derivan de System.ValueType, el cual deriva de System.Object. Los tipos que derivan de System.ValueType tienen un comportamiento especial en CLR. Las variables de tipo de valor contienen directamente sus valores. La memoria de un struct se asigna en línea en cualquier contexto en el que se declare la variable. No se produce ninguna asignación del montón independiente ni sobrecarga de la recolección de elementos no utilizados para las variables de tipo de valor. Puede declarar tipos record struct que son tipos de valor e incluir los miembros sintetizados para los registros.

Existen dos categorías de tipos de valor: struct y enum.

Los tipos numéricos integrados son structs y tienen campos y métodos a los que se puede acceder:

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

Pero se declaran y se les asignan valores como si fueran tipos simples no agregados:

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

Los tipos de valor están sellados. No se puede derivar un tipo de cualquier tipo de valor, por ejemplo System.Int32. No se puede definir un struct para que herede de cualquier clase o struct definido por el usuario porque un struct solo puede heredar de System.ValueType. A pesar de ello, un struct puede implementar una o más interfaces. No se puede convertir un tipo de struct en cualquier tipo de interfaz que implemente. Esta conversión provoca una que operación boxing encapsule el struct dentro de un objeto de tipo de referencia en el montón administrado. Las operaciones de conversión boxing se producen cuando se pasa un tipo de valor a un método que toma System.Object o cualquier tipo de interfaz como parámetro de entrada. Para obtener más información, vea Conversión boxing y unboxing.

Puede usar la palabra clave struct para crear sus propios tipos de valor personalizados. Normalmente, un struct se usa como un contenedor para un pequeño conjunto de variables relacionadas, como se muestra en el ejemplo siguiente:

public struct Coords
{
    public int x, y;

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

Para más información sobre estructuras, vea Tipos de estructura. Para más información sobre los tipos de valor, vea Tipos de valor.

La otra categoría de tipos de valor es enum. Una enumeración define un conjunto de constantes integrales con nombre. Por ejemplo, la enumeración System.IO.FileMode de la biblioteca de clases .NET contiene un conjunto de enteros constantes con nombre que especifican cómo se debe abrir un archivo. Se define como se muestra en el ejemplo siguiente:

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

La constante System.IO.FileMode.Create tiene un valor de 2. Sin embargo, el nombre es mucho más significativo para los humanos que leen el código fuente y, por esa razón, es mejor utilizar enumeraciones en lugar de números literales constantes. Para obtener más información, vea System.IO.FileMode.

Todas las enumeraciones se heredan de System.Enum, el cual se hereda de System.ValueType. Todas las reglas que se aplican a las estructuras también se aplican a las enumeraciones. Para más información sobre las enumeraciones, vea Tipos de enumeración.

Tipos de referencia

Un tipo que se define como class, record, delegate, matriz o interface es un reference type.

Al declarar una variable de un reference type, contiene el valor null hasta que se asigna con una instancia de ese tipo o se crea una mediante el operador new. La creación y asignación de una clase se muestran en el ejemplo siguiente:

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

No se puede crear una instancia de interface directamente mediante el operador new. En su lugar, cree y asigne una instancia de una clase que implemente la interfaz. Considere el ejemplo siguiente:

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();

Cuando se crea un objeto, se asigna memoria en el montón administrado. La variable contiene solo una referencia a la ubicación del objeto. Los tipos del montón administrado producen sobrecarga cuando se asignan y cuando se reclaman. La recolección de elementos no utilizados es la funcionalidad de administración automática de memoria de CLR, que realiza la recuperación. En cambio, la recolección de elementos no utilizados también está muy optimizada y no crea problemas de rendimiento en la mayoría de los escenarios. Para obtener más información sobre la recolección de elementos no utilizados, vea Administración de memoria automática.

Todas las matrices son tipos de referencia, incluso si sus elementos son tipos de valor. Las matrices derivan de manera implícita de la clase System.Array. El usuario las declara y las usa con la sintaxis simplificada que proporciona C#, como se muestra en el ejemplo siguiente:

// 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;

Los tipos de referencia admiten la herencia completamente. Al crear una clase, puede heredar de cualquier otra interfaz o clase que no esté definida como sellado. Otras clases pueden heredar de la clase e invalidar sus métodos virtuales. Para obtener más información sobre cómo crear sus clases, vea Clases, estructuras y registros. Para más información sobre la herencia y los métodos virtuales, vea Herencia.

Tipos de valores literales

En C#, los valores literales reciben un tipo del compilador. Puede especificar cómo debe escribirse un literal numérico; para ello, anexe una letra al final del número. Por ejemplo, para especificar que el valor 4.56 debe tratarse como un valor float, anexe "f" o "F" después del número: 4.56f. Si no se anexa ninguna letra, el compilador inferirá un tipo para el literal. Para obtener más información sobre los tipos que se pueden especificar con sufijos de letras, vea Tipos numéricos integrales y Tipos numéricos de punto flotante.

Dado que los literales tienen tipo y todos los tipos derivan en última instancia de System.Object, puede escribir y compilar código como el siguiente:

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

Los tipos se pueden declarar con uno o varios parámetros de tipo que actúan como un marcador de posición para el tipo real (el tipo concreto). El código de cliente proporciona el tipo concreto cuando crea una instancia del tipo. Estos tipos se denominan tipos genéricos. Por ejemplo, el tipo de .NET System.Collections.Generic.List<T> tiene un parámetro de tipo al que, por convención, se le denomina T. Cuando crea una instancia del tipo, especifica el tipo de los objetos que contendrá la lista, por ejemplo, string:

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

El uso del parámetro de tipo permite reutilizar la misma clase para incluir cualquier tipo de elemento, sin necesidad de convertir cada elemento en object. Las clases de colección genéricas se denominan colecciones con establecimiento inflexible de tipos porque el compilador conoce el tipo específico de los elementos de la colección y puede generar un error en tiempo de compilación si, por ejemplo, intenta agregar un valor entero al objeto stringList del ejemplo anterior. Para más información, vea Genéricos.

Tipos implícitos, tipos anónimos y tipos que admiten un valor NULL

Como se ha mencionado anteriormente, puede asignar implícitamente un tipo a una variable local (pero no miembros de clase) mediante la palabra clave var. La variable sigue recibiendo un tipo en tiempo de compilación, pero este lo proporciona el compilador. Para más información, vea Variables locales con asignación implícita de tipos.

En algunos casos, resulta conveniente crear un tipo con nombre para conjuntos sencillos de valores relacionados que no desea almacenar ni pasar fuera de los límites del método. Puede crear tipos anónimos para este fin. Para obtener más información, consulte Tipos anónimos (Guía de programación de C#).

Los tipos de valor normales no pueden tener un valor null, pero se pueden crear tipos de valor que aceptan valores NULL mediante la adición de ? después del tipo. Por ejemplo, int? es un tipo int que también puede tener el valor null. Los tipos que admiten un valor NULL son instancias del tipo struct genérico System.Nullable<T>. Los tipos que admiten un valor NULL son especialmente útiles cuando hay un intercambio de datos con bases de datos en las que los valores numéricos podrían ser null. Para más información, vea Tipos que admiten un valor NULL.

Tipo en tiempo de compilación y tipo en tiempo de ejecución

Una variable puede tener distintos tipos en tiempo de compilación y en tiempo de ejecución. El tipo en tiempo de compilación es el tipo declarado o inferido de la variable en el código fuente. El tipo en tiempo de ejecución es el tipo de la instancia a la que hace referencia esa variable. A menudo, estos dos tipos son los mismos, como en el ejemplo siguiente:

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

En otros casos, el tipo en tiempo de compilación es diferente, tal y como se muestra en los dos ejemplos siguientes:

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

En los dos ejemplos anteriores, el tipo en tiempo de ejecución es string. El tipo en tiempo de compilación es object en la primera línea y IEnumerable<char> en la segunda.

Si los dos tipos son diferentes para una variable, es importante comprender cuándo se aplican el tipo en tiempo de compilación y el tipo en tiempo de ejecución. El tipo en tiempo de compilación determina todas las acciones realizadas por el compilador. Estas acciones del compilador incluyen la resolución de llamadas a métodos, la resolución de sobrecarga y las conversiones implícitas y explícitas disponibles. El tipo en tiempo de ejecución determina todas las acciones que se resuelven en tiempo de ejecución. Estas acciones de tiempo de ejecución incluyen el envío de llamadas a métodos virtuales, la evaluación de expresiones is y switch y otras API de prueba de tipos. Para comprender mejor cómo interactúa el código con los tipos, debe reconocer qué acción se aplica a cada tipo.

Para más información, consulte los siguientes artículos.

Especificación del lenguaje C#

Para obtener más información, consulte la Especificación del lenguaje C#. La especificación del lenguaje es la fuente definitiva de la sintaxis y el uso de C#.