C# atrapa la mayoría de los errores antes de ejecutar, pero no todos: a veces deja pasar código que falla, y a veces rechaza código que parece correcto. Estas son trece rarezas de C#, compiladas y ejecutadas con .NET 10, con la explicación de por qué pasan y la costumbre que las evita.
C# es un lenguaje estricto: el compilador revisa los tipos antes de ejecutar nada y detiene la mayoría de los errores. Pero tiene rincones donde deja pasar código que falla, o donde rechaza código que parece correcto. Casi todos tienen una explicación histórica o de diseño, y conocerlos ahorra horas de depuración. Cada fragmento de esta página se compiló con el SDK de .NET 10 (C# 14), con los mensajes del compilador en español, y se ejecutó de verdad.
- Asignar null a un string compila
- Dos textos iguales que == dice que no lo son
- Un struct dentro de una lista no se puede cambiar
- double.Parse(«3.5») puede dar 35
- Math.Abs puede lanzar una excepción
- Una consulta LINQ se ejecuta cada vez que la usas
- Una excepción en async void no se puede capturar
- Las lambdas de un for ven todas el último valor
- T? no siempre significa «puede ser null»
- Dos DateTime iguales que no son la misma hora
- Un enum acepta cualquier número
- Dos records iguales que no son iguales
- En un switch no se puede pasar de un case a otro
Asignar null a un string compila
string nombre = null; Console.WriteLine(nombre.Length);
dotnet buildnull.cs(1,17): warning CS8600: Se va a convertir un literal nulo o un posible valor nulo en un tipo que no acepta valores NULL null.cs(2,19): warning CS8602: Desreferencia de una referencia posiblemente NULL.
salida con errorUnhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
Por qué: desde C# 8 el lenguaje distingue string (nunca null) de string? (puede serlo), pero solo con avisos, no con errores. Si fueran errores, todo el código escrito antes de 2019 dejaría de compilar. El programa compila, los dos warning pasan desapercibidos entre otros cien de un proyecto grande, y la excepción llega al ejecutar. Peor todavía es el operador !, que calla el aviso sin comprobar nada:
string nombre = null!; // «confía en mí»: ni un aviso Console.WriteLine(nombre.Length);
salida con errorUnhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
<WarningsAsErrors>nullable</WarningsAsErrors> al .csproj, y busca los null! y default! en las revisiones de código: cada uno es una promesa que nadie comprueba. Lo explicamos en la lección 2 del curso.Dos textos iguales que == dice que no lo son
string a = "hola"; string b = new string(['h', 'o', 'l', 'a']); Console.WriteLine(a == b); object oa = a; object ob = b; Console.WriteLine(oa == ob); Console.WriteLine(oa.Equals(ob)); Console.WriteLine(oa == b);
dotnet buildcomparar.cs(9,19): warning CS0252: Posible comparación de referencias no intencionada; para obtener una comparación de valores, convierta el lado de la izquierda en el tipo 'string'
salidaTrue False True False
Por qué: == no es un método que se elija al ejecutar, sino un operador que el compilador elige según el tipo declarado. Para dos string, usa el == de string, que compara el texto. Para dos object, usa el de object, que compara si son el mismo objeto en memoria, y aquí son dos objetos distintos con el mismo texto. Equals, en cambio, sí es virtual: aunque la variable sea object, se ejecuta el de string. En la última línea, el compilador al menos avisa.
string, o con string.Equals(a, b), que además admite StringComparison.OrdinalIgnoreCase para no distinguir mayúsculas. Y no ignores el aviso CS0252: casi siempre señala un error.Un struct dentro de una lista no se puede cambiar
var lista = new List<Punto> { new Punto { X = 1 } };
lista[0].X = 5;
struct Punto
{
public int X;
}dotnet buildstruct_lista.cs(2,1): error CS1612: No se puede modificar el valor devuelto de 'List<Punto>.this[int]' porque no es una variable
Con una class en lugar de struct, la misma línea compila y funciona. Y con un array de structs, también:
var lista = new List<Punto> { new Punto { X = 1 } };
var p = lista[0];
p.X = 5;
Console.WriteLine($"Lista: {lista[0].X}, copia: {p.X}");
Punto[] array = [new Punto { X = 1 }];
array[0].X = 5;
Console.WriteLine($"Array: {array[0].X}");salidaLista: 1, copia: 5 Array: 5
Por qué: un struct es un tipo de valor: copiarlo copia los datos. El indexador de List<T> es un método que devuelve el elemento, es decir, una copia. Cambiar esa copia temporal no serviría de nada, y el compilador lo prohíbe para que no creas que has cambiado la lista. Un array es distinto: array[0] es el propio elemento en su sitio de la memoria, no una copia, así que ahí sí se puede.
struct inmutables (readonly struct o record struct) y reemplaza el elemento entero: lista[0] = lista[0] with { X = 5 };. Si necesitas cambiar los datos en su sitio, usa una class. Las diferencias entre los dos están en la lección 5.double.Parse(«3.5») puede dar 35
using System.Globalization;
var es = new CultureInfo("es-ES");
Console.WriteLine(double.Parse("3,5", es));
Console.WriteLine(double.Parse("3.5", es));
Console.WriteLine(3.5.ToString(es));
Console.WriteLine(double.Parse("3.5", CultureInfo.InvariantCulture));salida3.5 35 3,5 3.5
Por qué: double.Parse, ToString y la interpolación usan por omisión el formato de números del sistema. En español, la coma separa los decimales y el punto, los miles, así que "3.5" se lee como 35: el punto se toma como separador de miles y se ignora. El mismo programa que funciona en el portátil del desarrollador, configurado en inglés, falla en el servidor o en el ordenador de un cliente en español, y sin lanzar ninguna excepción.
CultureInfo.InvariantCulture. Reserva la cultura del sistema para lo que lee una persona en pantalla.Math.Abs puede lanzar una excepción
int x = int.MinValue; Console.WriteLine(-x); Console.WriteLine(Math.Abs(x));
salida con error-2147483648 Unhandled exception. System.OverflowException: Negating the minimum value of a twos complement number is invalid.
Por qué: un int va de -2,147,483,648 a 2,147,483,647: hay un negativo más que positivos. El valor absoluto del mínimo, 2,147,483,648, no cabe. El operador - desborda en silencio y devuelve el mismo número negativo, como cualquier cuenta con enteros fuera de checked. Math.Abs, en cambio, prefiere fallar a devolver un valor absoluto negativo, que rompería cualquier código que confíe en él.
long antes de calcular su valor absoluto. Y recuerda que la aritmética normal de enteros desborda sin avisar, como se ve en la lección 2.Una consulta LINQ se ejecuta cada vez que la usas
var numeros = new List<int> { 1, 2, 3 };
var pares = numeros.Where(n =>
{
Console.WriteLine($" reviso el {n}");
return n % 2 == 0;
});
numeros.Add(4);
Console.WriteLine($"Hay {pares.Count()} pares");
Console.WriteLine($"El primero es {pares.First()}");salida reviso el 1 reviso el 2 reviso el 3 reviso el 4 Hay 2 pares reviso el 1 reviso el 2 El primero es 2
Por qué: Where no filtra nada cuando se llama: devuelve una consulta, una receta que se ejecuta al recorrerla. Por eso ve el 4 que se añadió después, y por eso vuelve a ejecutarse con cada Count(), First() o foreach (First() al menos se detiene en cuanto encuentra un par). Con una lista de tres números da igual; con una consulta que lee un archivo o llama a una base de datos, la segunda vuelta repite todo el trabajo.
.ToList() o .ToArray() al final de la consulta. Y no pongas efectos (escribir, modificar) dentro de un Where o un Select. LINQ tiene su lección: la 7.Una excepción en async void no se puede capturar
try
{
Avisar();
await Task.Delay(300);
Console.WriteLine("Fin");
}
catch (Exception e)
{
Console.WriteLine($"Capturada: {e.Message}");
}
async void Avisar()
{
await Task.Yield();
throw new InvalidOperationException("fallo dentro de async void");
}salida con errorUnhandled exception. System.InvalidOperationException: fallo dentro de async void
Por qué: un método async Task guarda su excepción dentro de la Task que devuelve, y quien la espera con await la recibe y puede capturarla. Un método async void no devuelve nada donde guardarla: cuando falla después del primer await, la excepción se lanza «al aire», fuera de cualquier try, y termina el proceso entero. El catch de arriba nunca llega a enterarse. Con async Task y await, el mismo código funciona como se espera:
try
{
await Avisar();
Console.WriteLine("Fin");
}
catch (Exception e)
{
Console.WriteLine($"Capturada: {e.Message}");
}
async Task Avisar()
{
await Task.Yield();
throw new InvalidOperationException("fallo dentro de async Task");
}salidaCapturada: fallo dentro de async Task
async void solo existe para los manejadores de eventos de las interfaces gráficas (button.Click += async (s, e) => ...). En cualquier otro sitio, devuelve Task y espérala con await.Las lambdas de un for ven todas el último valor
var conFor = new List<Action>();
for (int i = 0; i < 3; i++)
{
conFor.Add(() => Console.Write($"{i} "));
}
foreach (var accion in conFor) accion();
Console.WriteLine();
var conForeach = new List<Action>();
foreach (var n in new[] { 0, 1, 2 })
{
conForeach.Add(() => Console.Write($"{n} "));
}
foreach (var accion in conForeach) accion();
Console.WriteLine();salida3 3 3 0 1 2
Por qué: una lambda no copia las variables que usa: las captura, guarda una referencia a ellas. En un for hay una sola variable i para todo el bucle, que al terminar vale 3, y las tres lambdas la ven así al ejecutarse después. En un foreach, desde C# 5 (2012), cada vuelta tiene su propia variable n: se cambió precisamente porque este fallo era uno de los más frecuentes del lenguaje. El for se dejó como estaba.
for, copia la variable a una local antes de capturarla (int copia = i;) o usa foreach. Pasa lo mismo con las tareas que lanzas dentro de un bucle: Task.Run(() => Procesar(i)).T? no siempre significa «puede ser null»
Console.WriteLine(Primero(new List<string>()) ?? "(null)"); Console.WriteLine(Primero(new List<int>())); int? resultado = Primero(new List<int>()); Console.WriteLine(resultado.HasValue); T? Primero<T>(List<T> lista) => lista.Count > 0 ? lista[0] : default;
salida(null) 0 True
Por qué: en un genérico sin restricciones, T? no puede significar lo mismo para todos los tipos. Para un tipo de referencia, string? es solo una anotación: el valor puede ser null. Pero para un tipo de valor, int? es otro tipo distinto (Nullable<int>), y un método genérico no puede devolver unas veces int y otras int?. Así que, con T = int, T? se queda en int, y default es 0. La lista vacía y la lista que empieza por 0 dan el mismo resultado, y convertirlo luego a int? no lo arregla: ya es un 0 de verdad.
bool y el valor con out (el patrón TryGet, como Dictionary.TryGetValue), o restringe el tipo con where T : struct o where T : class para que T? signifique lo que parece. Los genéricos están en la lección 6.Dos DateTime iguales que no son la misma hora
var enUtc = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
var enMexico = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Unspecified);
Console.WriteLine(enUtc == enMexico);
Console.WriteLine($"{enUtc.Kind} / {enMexico.Kind}");
var a = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
var b = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.FromHours(-6));
Console.WriteLine(a == b);
Console.WriteLine(b - a);salidaTrue Utc / Unspecified False 06:00:00
Por qué: un DateTime es un número de «tics» más una etiqueta, Kind, que dice si es UTC, hora local o no se sabe. Al comparar, sumar o restar, la etiqueta se ignora: las 12:00 en UTC y las 12:00 de la Ciudad de México son «iguales» aunque haya seis horas entre ellas. DateTimeOffset guarda el desfase respecto a UTC y compara instantes reales: ahí la diferencia son las seis horas que esperas.
DateTime.UtcNow) o, mejor, con DateTimeOffset, y convierte a la hora local solo para mostrarla. Para fechas sin hora, como un cumpleaños, existe DateOnly. Hay más en el ejemplo de fechas.Un enum acepta cualquier número
Color c = (Color)42;
Console.WriteLine(c);
Console.WriteLine(Enum.IsDefined(c));
Color sinAsignar = default;
Console.WriteLine(sinAsignar);
enum Color { Rojo, Verde, Azul }salida42 False Rojo
Por qué: un enum es un int con nombres: el tipo no se limita a los valores que declaras. Cualquier número se puede convertir, y al escribirlo sale el número porque no hay nombre que poner. Además, el valor por omisión de un enum es 0, que aquí es Rojo: una variable que nadie ha asignado parece una elección hecha a propósito.
Enum.IsDefined los valores que llegan de fuera (una base de datos, un JSON, un número que escribe el usuario), y reserva el 0 para un valor que signifique «sin definir»: enum Color { Ninguno, Rojo, Verde, Azul }.Dos records iguales que no son iguales
var a = new Pedido("A-1", [1, 2]);
var b = new Pedido("A-1", [1, 2]);
Console.WriteLine(a == b);
Console.WriteLine(a);
var c = new Pedido("A-1", a.Lineas);
Console.WriteLine(a == c);
record Pedido(string Id, int[] Lineas);salidaFalse
Pedido { Id = A-1, Lineas = System.Int32[] }
TruePor qué: los record comparan por valor: dos records son iguales si todas sus propiedades lo son. Pero «igual» se decide con el Equals de cada propiedad, y el de un array (o una List) compara si es el mismo array, no su contenido. Dos pedidos con arrays distintos de idéntico contenido son diferentes; con el mismo array, iguales. El ToString automático tampoco entra en el array y escribe su tipo, System.Int32[].
Equals comparando las colecciones con SequenceEqual. Los records están en la lección 5.En un switch no se puede pasar de un case a otro
int n = 1;
switch (n)
{
case 1:
Console.WriteLine("uno");
case 2:
Console.WriteLine("dos");
break;
}dotnet buildswitch.cs(4,5): error CS0163: El control no puede pasar explícitamente de una etiqueta case ('case 1:') a otraPor qué: en C, C++, Java y JavaScript, un case sin break sigue ejecutando el siguiente: es la fuente de errores más clásica del switch. C# decidió que olvidar el break fuera un error de compilación. Si quieres pasar al siguiente de verdad, hay que escribirlo: goto case 2;. Lo que sí se permite es apilar varias etiquetas sin código entre ellas, como case 1: case 2:.
var texto = n switch { 1 => "uno", 2 => "dos", _ => "otro" };) no tiene break que olvidar. Está en la lección 3.Resumen: la regla detrás de cada rareza
| Rareza | La causa | La costumbre que la evita |
|---|---|---|
null en un string | Los avisos de null no son errores, por compatibilidad | WarningsAsErrors y desconfiar de ! |
== entre objetos | El operador se elige por el tipo declarado | string.Equals o variables string |
struct en una lista | Leer un tipo de valor crea una copia | Structs inmutables o una class |
double.Parse | Usa el formato de números del sistema | CultureInfo.InvariantCulture |
Math.Abs(int.MinValue) | Hay un negativo más que positivos | Pasar a long |
| LINQ | Una consulta es una receta, no un resultado | .ToList() si se usa más de una vez |
async void | No hay Task donde guardar la excepción | async Task y await |
Lambdas en un for | Capturan la variable, no su valor | Una copia local o foreach |
T? en un genérico | Para tipos de valor no puede añadir el null | TryGet o una restricción |
DateTime | Kind no cuenta al comparar | UTC o DateTimeOffset |
enum | Es un número con nombres | Enum.IsDefined y un 0 «ninguno» |
record con un array | El array se compara por referencia | Tipos con igualdad por valor |
switch sin break | C# prohíbe caer al siguiente caso | Switch de expresión |
¿Quieres comprobar si ya piensas como el compilador? Prueba el test de C#, con 24 preguntas sobre si un código compila y qué escribe. Y si alguna de estas rarezas te pilló por sorpresa, el curso de C# explica la base de cada una.
Todo el código de esta página se compiló con el .NET SDK 10.0.403 (C# 14) y se ejecutó con .NET 10.0.14 en un contenedor limpio antes de publicarla; los mensajes del compilador y las salidas están copiados de esa ejecución.
