Un struct es un tipo de valor que defines tú. Se parece a una clase, con campos, propiedades, métodos y constructores, pero una variable de tipo struct contiene los datos directamente en lugar de una referencia a un objeto. Esa única diferencia cambia cómo se comportan la asignación, las llamadas a métodos, la igualdad y null.
Semántica de valor: asignar copia
Los tipos numéricos incorporados, bool, char, DateTime, TimeSpan y Guid son todos structs. Los tuyos funcionan de la misma forma:
Salida:
struct: s1.X = 1, s2.X = 50
class: c1.X = 50, c2.X = 50
after Move: s1.X = 1, c1.X = 150
Con el struct, s2 es una copia independiente, y Move también trabaja sobre su propia copia. Para que un método cambie el struct de quien llama, pásalo por referencia con ref (consulta ref y out). Para evitar copiar un struct grande sin permitir cambios, C# 7.2 añadió los parámetros in.
Otras consecuencias de ser un tipo de valor:
- Una variable struct nunca puede ser
null.PointS p = null;no compila. UsaPointS?(un tipo de valor nullable) cuando "ningún valor" tenga sentido. - Un struct sin inicializar es todo ceros: campos numéricos a
0, camposboolafalse, campos de referencia anull. - Un struct no puede heredar de otro struct ni de una clase, y nada puede heredar de un struct. Sí puede implementar interfaces.
- Las variables struct locales suelen vivir en la pila o dentro del objeto que las contiene, así que crear una no reserva memoria en el heap.
La trampa de la List de structs: CS1612
Como leer un struct produce una copia, esta línea de aspecto inocente no compila:
var points = new List<PointS> { new PointS(1, 2) };
points[0].X = 10;
// error CS1612: Cannot modify the return value of 'List<PointS>.this[int]' because it is not a variable
El indexador de List<T> es un método que devuelve una copia del elemento. Asignar X en esa copia temporal no cambiaría nada, así que el compilador te detiene. El mismo error aparece cuando una propiedad devuelve un struct: order.Location.X = 10. Copia, modifica y vuelve a escribir:
Salida:
35
7
Los arrays son la excepción: array[0] es el propio elemento, no una copia. Este dolor recurrente es el motivo del consejo habitual de hacer inmutables los structs: si un struct no puede modificarse, no se pierde nada al modificar una copia.
Constructores y valores por defecto
De C# 7 a C# 9, las reglas de los constructores de structs son estrictas:
- No puedes declarar un constructor sin parámetros.
new PointS()siempre existe y pone a cero todos los campos. - Un constructor que sí declares debe asignar todos los campos (y las propiedades automáticas) antes de volver.
- No se permiten inicializadores en los campos de instancia (
public int X = 1;).
Salida:
19.90 EUR
0.00 (none)
0.00 (none)
0.00 (none)
Money también muestra por qué importa el valor por defecto: un Money a cero tiene una moneda null, y tu código debe manejarlo, porque los arrays, default(T) y los campos sin inicializar producen uno.
Las versiones más recientes relajaron estas reglas:
// C# 10: explicit parameterless constructors and field initializers
public struct Settings
{
public int Retries = 3;
public Settings() { }
}
// C# 11: fields you do not assign in a constructor are zeroed automatically,
// instead of being a compile error.
Un constructor sin parámetros de C# 10 se ejecuta con new Settings() pero no con default(Settings) ni con los elementos de un array, que siguen siendo todo ceros. Esa diferencia sorprende a la gente, así que úsalo con cuidado.
readonly struct (C# 7.2)
Marcar el propio struct como readonly hace que el compilador imponga la inmutabilidad: todos los campos deben ser readonly y todas las propiedades automáticas de solo lectura.
public readonly struct Temperature
{
public double Celsius { get; }
public Temperature(double celsius) { Celsius = celsius; }
public Temperature WarmerBy(double delta) => new Temperature(Celsius + delta); // returns a new value
}
Además de documentar la intención, ayuda al rendimiento: cuando un struct que no es readonly se guarda en un campo readonly o se pasa como parámetro in, el compilador lo copia antes de cada llamada a un método (no puede saber si el método lo modificará). Un readonly struct no necesita esas copias defensivas. En C# 7.0 puedes seguir haciendo readonly todos los campos, como hace Money con propiedades de solo lectura: eso te da la inmutabilidad, pero no las copias ahorradas, porque el compilador solo se fía de un struct declarado readonly.
Igualdad
Equals en un struct compara campo a campo por defecto, que es el comportamiento de valor que quieres. Pero la implementación por defecto (ValueType.Equals) puede usar reflexión y es lenta, y el operador == no está definido: a == b sobre tu propio struct es el error CS0019. Implementa los dos cuando el struct se vaya a comparar:
Salida:
True
True
True
Implementar IEquatable<T> importa en las colecciones: HashSet<T>, Dictionary<TKey, TValue> y List<T>.Contains llaman directamente a Equals(GridCell) en lugar de hacer boxing de cada valor para llamar a Equals(object). Los record structs (más abajo) generan todo esto por ti.
Boxing
Convertir un struct a object o a un tipo de interfaz le hace boxing: el runtime copia el valor en un objeto nuevo del heap. La caja y el original son independientes desde ese momento:
Salida:
2
0
El boxing cuesta una reserva de memoria cada vez, y por eso las antiguas colecciones no genéricas como ArrayList eran lentas con los tipos de valor y los genéricos las sustituyeron. También significa que un struct mutable al que se accede a través de una interfaz se modifica en su caja, no en el original, otro motivo más para mantener inmutables los structs.
Struct frente a class
| struct | class | |
|---|---|---|
| Tipo | tipo de valor | tipo de referencia |
| Asignación y parámetros | copian los datos | copian la referencia |
Puede ser null | no (T? sí) | sí |
| Valor por defecto | todos los campos a cero | null |
| Herencia | ninguna; puede implementar interfaces | una clase base, interfaces |
== | no definido salvo que lo sobrecargues | igualdad de referencias salvo sobrecarga |
Equals por defecto | compara los campos | compara las referencias |
| Reserva de memoria | en línea (pila u objeto contenedor) | heap, con recolección de basura |
| Adecuado para | valores pequeños e inmutables | entidades, estado grande o compartido |
Cuándo usar un struct
Elige un struct cuando se cumpla todo esto: el tipo es un único valor lógico (una coordenada, una cantidad de dinero, un color, un rango de fechas), es pequeño (la pauta de Microsoft es de unos 16 bytes, más o menos cuatro int), es inmutable y no necesitas herencia. Los structs compensan cuando creas muchísimos, por ejemplo millones de puntos en un array, porque evitan una reserva en el heap y una entrada para el recolector de basura por elemento.
Elige una clase para todo lo que tenga identidad (un cliente, un pedido), todo lo grande, todo lo que se modifique desde varios sitios, y siempre que tengas dudas. Un struct grande y mutable te da los costes de copiar más la confusión de modificar copias.
record struct (C# 10)
C# 10 añadió record struct, que genera en una línea la igualdad por valor, ==, ToString y el soporte de with para un struct:
public readonly record struct Coordinate(double Lat, double Lng);
var home = new Coordinate(38.72, -9.14);
var same = new Coordinate(38.72, -9.14);
Console.WriteLine(home == same); // True
Console.WriteLine(home); // Coordinate { Lat = 38.72, Lng = -9.14 }
var north = home with { Lat = 38.80 };
Un record struct normal tiene propiedades posicionales mutables; readonly record struct las hace init-only, que suele ser lo que quieres. La página de records trata la versión de clase y muestra los miembros que escribe el compilador.
Errores comunes
- Modificar un struct a través de una copia.
list[0].X = 1(CS1612), una variable deforeach(CS1654), el resultado del getter de una propiedad. Vuelve a escribir la copia modificada, o haz inmutable el struct. - Structs grandes. Cada asignación y cada llamada lo copian entero. Pasados unos pocos campos, una clase suele ser más rápida.
- Olvidar el valor por defecto a cero. Los arrays y
default(T)crean valores struct sin ejecutar tu constructor, así que los campos que validas ahí pueden seguir siendo cero onull. - Comparar con
==sin haberlo definido. CS0019. ImplementaIEquatable<T>y los operadores, o usa un record struct. - Structs mutables detrás de interfaces. El boxing hace que el cambio ocurra en una copia que no estás mirando.
Preguntas frecuentes
¿Qué es un struct en C#?
Un struct es un tipo de valor definido por el usuario: struct Point { public int X; public int Y; }. Una variable de tipo struct contiene los propios datos en lugar de una referencia a un objeto, así que la asignación y el paso a un método copian el valor entero. int, double, DateTime y Guid son todos structs.
¿Qué diferencia hay entre un struct y una clase en C#?
Una clase es un tipo de referencia: las variables comparten un objeto, y se permite null. Un struct es un tipo de valor: cada variable tiene su propia copia, no puede ser null (salvo que uses Point?), no puede heredar ni ser heredado, y su valor por defecto tiene todos los campos a cero. Los structs encajan con valores pequeños e inmutables; las clases, con entidades que tienen identidad y comportamiento.
¿Cuándo debo usar un struct en lugar de una clase en C#?
Cuando el tipo representa un solo valor pequeño (una coordenada, una cantidad de dinero, un rango de fechas), idealmente es inmutable, se compara por su contenido y se crea en grandes cantidades donde evitar reservas en el heap importa. La guía de Microsoft añade un tamaño de unos 16 bytes o menos. Si el tipo tiene identidad, muchos campos o se modifica a través de referencias, usa una clase.
¿Por qué obtengo "Cannot modify the return value because it is not a variable"?
Es el error CS1612, normalmente por list[0].X = 5 sobre una List<Point> de structs. El indexador devuelve una copia del struct, así que el cambio en la copia se perdería, y el compilador se niega. Copia el elemento en una variable, cámbialo y vuelve a asignarlo: var p = list[0]; p.X = 5; list[0] = p;. Los arrays no tienen este problema porque array[0] se refiere al propio elemento.
¿Qué es un readonly struct en C#?
readonly struct (C# 7.2) declara que ningún miembro del struct modifica su estado: todos los campos deben ser readonly y las propiedades automáticas de solo lectura. El compilador lo hace cumplir, y eso le permite evitar copias defensivas cuando el struct se pasa con in o se guarda en un campo readonly. La mayoría de los structs deberían ser readonly.