
No es ningún secreto que Microsoft ha estado trabajando en el lanzamiento de la octava versión de C # durante bastante tiempo. En la versión reciente de Visual Studio 2019, ya está disponible una nueva versión del lenguaje (C # 8.0), pero hasta ahora solo como versión beta. Los planes para esta nueva versión tienen varias características, cuya implementación puede no parecer bastante obvia, o más bien, no del todo esperada. Una de estas innovaciones es la capacidad de utilizar tipos de referencia anulables. El significado declarado de esta innovación es la lucha contra las Excepciones de Referencia Nula (NRE).
Nos complace que el lenguaje se esté desarrollando y que las nuevas funciones ayuden a los desarrolladores. Casualmente, en nuestro analizador PVS-Studio para C #, las capacidades para detectar exactamente la misma NRE en el código se han expandido relativamente recientemente. Y nos preguntamos: ¿hay algún sentido ahora para los analizadores estáticos en general, y para PVS-Studio en particular, para tratar de buscar una posible desreferenciación de referencias nulas, si, al menos en el nuevo código que usa la Referencia Nulable, dicha desreferencia será "imposible" ? Intentemos responder esta pregunta.
Pros y contras de la innovación
Para comenzar, vale la pena recordar que en la última versión beta de C # 8.0, disponible al momento de escribir este artículo, la Referencia Nulable está desactivada de manera predeterminada, es decir. El comportamiento de los tipos de referencia no cambiará.
¿Cuáles son los tipos de referencia anulables en C # 8.0 si los incluye? Este es el mismo tipo de referencia antiguo y bueno, con la diferencia de que las variables de este tipo ahora deben marcarse con '?' (por ejemplo,
cadena? ), similar a como ya se hace para
Nullable <T> , es decir tipos significativos anulables (por ejemplo,
int? ). Sin embargo, ahora la misma
cadena sin '?' ya comienza a interpretarse como una referencia no anulable, es decir Este es un tipo de referencia cuya variable no puede contener valores
nulos .
La excepción de referencia nula es una de las excepciones más molestas porque dice poco sobre el origen del problema, especialmente si hay varias desreferencias seguidas en el método que arroja la excepción. La capacidad de prohibir pasar
nulo a una variable de tipo de referencia se ve bien, pero si antes
se pasó
nulo al método y alguna lógica de ejecución adicional estaba vinculada a esto, ¿qué debo hacer ahora? Por supuesto, puede pasar un valor literal, constante o simplemente "imposible" en lugar de
nulo , que, según la lógica del programa, no puede asignarse a esta variable en ningún otro lugar. Sin embargo, la caída de todo el programa puede ser reemplazada por una ejecución incorrecta "silenciosa" adicional. No siempre será mejor que ver el error de inmediato.
¿Y si en cambio arroja una excepción? Una excepción significativa en un lugar donde algo salió mal siempre es mejor que una
NRE en algún lugar más alto o más bajo en la pila. Pero es bueno si estamos hablando de nuestro propio proyecto, donde podemos arreglar a los consumidores e insertar un
bloque try-catch, y al desarrollar una biblioteca usando la referencia (no) Nullable, asumimos la responsabilidad de que algún método siempre devuelve un valor. Y no siempre es, incluso en el código nativo, que será (al menos simple) sustituir el retorno
nulo por lanzar una excepción (demasiado código puede verse afectado).
Puede habilitar Nullable Reference en todo el nivel del proyecto agregando la propiedad
NullableContextOptions con el valor de
habilitación , o en el nivel de archivo utilizando la directiva de preprocesador:
#nullable enable string cantBeNull = string.Empty; string? canBeNull = null; cantBeNull = canBeNull!;
Los tipos ahora serán más visuales. Mediante la firma del método, es posible determinar su comportamiento, si contiene una comprobación de
nulo o no, puede devolver
nulo o no. Ahora, si intenta acceder a una variable de referencia anulable sin verificar, el compilador generará una advertencia.
Muy conveniente cuando se usan bibliotecas de terceros, pero existe una situación con posible información errónea. El hecho es que pasar
nulo todavía es posible, por ejemplo, utilizando el nuevo operador que perdona nulo (!). Es decir Es solo que con la ayuda de un solo signo de exclamación, puede romper todos los supuestos adicionales que se harán sobre una interfaz que utiliza estas variables:
#nullable enable String GetStr() { return _count > 0 ? _str : null!; } String str = GetStr(); var len = str.Length;
Sí, se puede decir que está mal escribir de esta manera, y nadie lo hará, pero mientras permanezca esta oportunidad, ya no es posible confiar completamente en el contrato impuesto por la interfaz de este método (que no puede devolver nulo).
¡Y, por cierto, puede escribir lo mismo con la ayuda de varios operadores !, porque C # ahora le permite escribir así (y este código está completamente compilado):
cantBeNull = canBeNull!!!!!!!;
Es decir nos gustaría enfatizar aún más: presta atención, ¡esto puede ser
nulo ! (nosotros en el equipo llamamos a esto programación "emocional"). De hecho, el compilador (de Roslyn), cuando construye un árbol de código de sintaxis, ¡interpreta al operador! similar a los corchetes simples, por lo que su número, como es el caso con los corchetes, es ilimitado. Aunque, si escribe muchos de ellos, el compilador puede ser "volcado". Quizás esto cambie en la versión final de C # 8.0.
De manera similar, puede omitir la advertencia del compilador al acceder a una variable de referencia anulable sin verificar:
canBeNull!.ToString();
Puedes escribir más emocionalmente:
canBeNull!!!?.ToString();
Esta sintaxis es realmente difícil de imaginar en un proyecto real, poniendo un operador
indulgente que le decimos al compilador: aquí todo está bien, no se necesita verificación. Añadiendo un operador de elvis decimos: pero en general puede no ser normal, verifiquemos.
Y ahora surge una pregunta legítima: ¿por qué, si el concepto de un tipo de referencia no anulable implica que una variable de este tipo no puede contener
nulo , podemos seguir escribiéndolo tan fácilmente allí? El hecho es que "bajo el capó", en el nivel del código IL, nuestro tipo de referencia no anulable permanece ... todo el mismo tipo de referencia "ordinario". Y toda la sintaxis de nulabilidad es en realidad solo una anotación para el analizador estático integrado en el compilador (y, en nuestra opinión, no es el analizador más conveniente, pero más sobre eso más adelante). En nuestra opinión, incluir la nueva sintaxis en el lenguaje solo como una anotación para una herramienta de terceros (incluso si está integrada en el compilador) no es la solución más "hermosa", porque para un programador que usa este lenguaje, esto es solo una anotación puede no ser obvio en absoluto; después de todo, una sintaxis muy similar para estructuras anulables funciona de una manera completamente diferente.
Volviendo a cómo todavía es posible "romper" los tipos de referencia anulables. En el momento de la escritura, si hay varios proyectos en la solución, al pasar de un método declarado en un proyecto una variable de referencia, por ejemplo de tipo
String, a un método de otro proyecto donde
NullableContextOptions está habilitado
, el compilador decidirá que ya es una String no anulable, Y no dará una advertencia. Y esto a pesar de la gran cantidad de
atributos [Nullable (1)] agregados a cada campo y método de clase en el código IL cuando las referencias Nullable están activadas
. Por cierto, estos atributos deben tenerse en cuenta si está trabajando con una lista de atributos a través de la reflexión, contando con la existencia de solo aquellos atributos que agregó usted mismo.
Esta situación puede crear problemas adicionales al convertir una base de código grande en una referencia anulable. Lo más probable es que este proceso sea gradual, proyecto por proyecto. Por supuesto, con un enfoque competente para el cambio, puede cambiar gradualmente a un nuevo funcional, pero si ya tiene un borrador funcional, cualquier cambio en él es peligroso e indeseable (funciona, ¡no lo toque!). Es por eso que cuando se usa el analizador PVS-Studio no hay necesidad de editar el código fuente o de alguna manera marcarlo para detectar
NRE potenciales. Para verificar los lugares donde puede ocurrir una
NullReferenceException, solo necesita iniciar el analizador y mirar las advertencias V3080. No es necesario cambiar las propiedades del proyecto o el código fuente. No es necesario agregar directivas, atributos u operadores. No es necesario cambiar su código.
Con el soporte de los tipos de referencia anulables en el analizador PVS-Studio, nos enfrentamos a una opción: ¿el analizador debe interpretar las variables de referencia no anulables como valores siempre distintos de cero? Después de estudiar el tema de las posibilidades de "romper" esta garantía, llegamos a la conclusión de que no existe: el analizador no debe hacer tal suposición. De hecho, incluso si se usan tipos de referencia no anulables en todas partes del proyecto, el analizador puede complementar su uso simplemente descubriendo situaciones en las que puede aparecer un valor
nulo en dicha variable.
Cómo se ve PVS-Studio para excepciones de referencia nula
Los mecanismos de flujo de datos en el analizador C # PVS-Studio monitorean los posibles valores de las variables durante el análisis. En particular, PVS-Studio también realiza análisis interprocediales, es decir Intenta determinar el posible valor devuelto por el método, así como los métodos invocados en este método, etc. Entre otras cosas, el analizador recuerda variables que pueden ser potencialmente
nulas . Si en el futuro el analizador ve una desreferenciación sin verificar dicha variable, nuevamente, ya sea en el código actual que se está verificando o dentro del método llamado en este código, se emitirá una advertencia V3080 sobre una posible excepción de referencia nula.
Al mismo tiempo, la idea principal que subyace a este diagnóstico es que el analizador solo jurará si vio en algún lugar la asignación de
nulo a una variable. Esta es la principal diferencia entre el comportamiento de este diagnóstico y el analizador integrado en el compilador que funciona con tipos de referencia anulables. ¡El analizador integrado en el compilador jurará ante cualquier desreferencia de una variable de referencia anulable no verificada del tipo, a menos que, por supuesto, este analizador sea "engañado" por el operador! de cualquier otra manera, se puede utilizar absolutamente cualquier analizador, especialmente si se establece ese objetivo, y PVS-Studio no es una excepción).
PVS-Studio solo jura si ve
nulo (en un contexto local o proviene de un método). Al mismo tiempo, incluso si la variable es una variable de referencia no anulable, el comportamiento del analizador no cambiará, seguirá jurando si ve que se le escribió nulo. Este enfoque nos parece más correcto (o, al menos, conveniente para el usuario del analizador), ya que no requiere "cubrir" todo el código con verificaciones
nulas para encontrar posibles desreferencias; esto podría haberse hecho antes, sin una Referencia Anulable, por ejemplo, con los mismos contratos. Además, el analizador ahora se puede usar para un control adicional sobre las mismas variables de referencia no anulables. Si se usan "honestamente" y nunca se asignan nulos, el analizador permanecerá en silencio. Si se asigna un valor nulo y la variable se desreferencia sin verificar, el analizador lo advierte con el mensaje V3080:
#nullable enable String GetStr() { return _count > 0 ? _str : null!; } String str = GetStr(); var len = str.Length; <== V3080: Possible null dereference. Consider inspecting 'str'
Consideremos algunos ejemplos de tal activación de diagnósticos V3080 en el código de Roslyn.
Verificamos este proyecto no hace mucho tiempo, pero esta vez consideraremos solo los posibles desencadenantes de excepción de referencia nula que no estaban en artículos anteriores. Veamos cómo el analizador PVS-Studio puede encontrar la posible desreferenciación de referencias nulas, y cómo estos lugares se pueden arreglar utilizando la nueva sintaxis de referencia anulable.
V3080 [CWE-476] Posible desreferencia nula dentro del método. Considere inspeccionar el segundo argumento: chainedTupleType. Microsoft.CodeAnalysis.CSharp TupleTypeSymbol.cs 244 NamedTypeSymbol chainedTupleType; if (_underlyingType.Arity < TupleTypeSymbol.RestPosition) { .... chainedTupleType = null; } else { .... } return Create(ConstructTupleUnderlyingType(firstTupleType, chainedTupleType, newElementTypes), elementNames: _elementNames);
Como puede ver, la variable
chainedTupleType puede ser nula en una de las ramas de ejecución de código. Luego,
chainedTupleType se pasa dentro del método
ConstructTupleUnderlyingType, y se usa allí con la verificación a través de
Debug.Assert . Esta situación es muy común en Roslyn, sin embargo, vale la pena recordar que
Debug.Assert se elimina en la versión de lanzamiento del ensamblado. Por lo tanto, el analizador aún considera que la desreferenciación dentro del método
ConstructTupleUnderlyingType es peligrosa. A continuación, damos el cuerpo de este método, donde ocurre la desreferenciación:
internal static NamedTypeSymbol ConstructTupleUnderlyingType( NamedTypeSymbol firstTupleType, NamedTypeSymbol chainedTupleTypeOpt, ImmutableArray<TypeWithAnnotations> elementTypes) { Debug.Assert (chainedTupleTypeOpt is null == elementTypes.Length < RestPosition); .... while (loop > 0) { .... currentSymbol = chainedTupleTypeOpt.Construct(chainedTypes); loop--; } return currentSymbol; }
Si el analizador debe tener en cuenta tal afirmación es realmente un punto discutible (algunos de nuestros usuarios quieren que lo haga), porque los contratos de System.Diagnostics.Contracts, por ejemplo, el analizador ahora tiene en cuenta. Le diré solo un pequeño ejemplo de nuestro uso real de la misma Roslyn en nuestro analizador. Recientemente,
admitimos la nueva versión de Visual Studio y, al mismo tiempo, actualizamos el analizador Roslyn a la versión 3. Después de eso, el analizador comenzó a caer al verificar un cierto código en el que no se había bloqueado previamente. Al mismo tiempo, el analizador comenzó a caer no dentro de nuestro código, sino dentro del propio código de Roslyn, para caer con una excepción de referencia nula. Y una mayor depuración mostró que en el lugar donde ahora cae Roslyn, exactamente un par de líneas arriba, hay la misma verificación
nula a través de
Debug.Assert . Y ella, como vemos, no salvó.
Este es un muy buen ejemplo de problemas con la referencia anulable
, porque el compilador considera
Debug.Assert una verificación válida en cualquier configuración. Es decir, si simplemente habilita
#nullable enable y marca el argumento
chainedTupleTypeOpt como referencia anulable
, no habrá advertencias del compilador en la ubicación de desreferencia en el método
ConstructTupleUnderlyingType .
Considere el siguiente ejemplo de activación de PVS-Studio.
V3080 Posible desreferencia nula. Considere inspeccionar 'efectivoRuleset'. RuleSet.cs 146 var effectiveRuleset = ruleSet.GetEffectiveRuleSet(includedRulesetPaths); effectiveRuleset = effectiveRuleset.WithEffectiveAction(ruleSetInclude.Action); if (IsStricterThan(effectiveRuleset.GeneralDiagnosticOption, ....)) effectiveGeneralOption = effectiveRuleset.GeneralDiagnosticOption;
Esta advertencia señala que llamar al método
WithEffectiveAction puede devolver un
valor nulo , pero el resultado se usa sin verificar (
efectivoRuleset.GeneralDiagnosticOption ). El cuerpo del método
WithEffectiveAction , que puede devolver nulo, se escribe en la variable
efectivaRuleset :
public RuleSet WithEffectiveAction(ReportDiagnostic action) { if (!_includes.IsEmpty) throw new ArgumentException(....); switch (action) { case ReportDiagnostic.Default: return this; case ReportDiagnostic.Suppress: return null; .... return new RuleSet(....); default: return null; } }
Si habilita el modo Nullable Reference para el método
GetEffectiveRuleSet , tendremos dos lugares en los que debemos cambiar el comportamiento. Dado que hay un lanzamiento de excepción en el método anterior, es lógico suponer que la llamada al método está envuelta en un
bloque try-catch y reescribirá correctamente el método, arrojando una excepción en lugar de devolver nulo. Pero al escalar los desafíos, vemos que la intercepción es alta y las consecuencias pueden ser bastante impredecibles. Veamos la variable de consumo
efectivoRuleset - método
IsStricterThan private static bool IsStricterThan(ReportDiagnostic action1, ReportDiagnostic action2) { switch (action2) { case ReportDiagnostic.Suppress: ....; case ReportDiagnostic.Warn: return action1 == ReportDiagnostic.Error; case ReportDiagnostic.Error: return false; default: return false; } }
Como puede ver, este es un cambio simple para dos enumeraciones con un posible valor de enumeración de
ReportDiagnostic.Default . Por lo tanto, es mejor reescribir la llamada de la siguiente manera:
La firma
WithEffectiveAction cambiará:
#nullable enable public RuleSet? WithEffectiveAction(ReportDiagnostic action)
la llamada se verá así:
RuleSet? effectiveRuleset = ruleSet.GetEffectiveRuleSet(includedRulesetPaths); effectiveRuleset = effectiveRuleset?.WithEffectiveAction(ruleSetInclude.Action); if (IsStricterThan(effectiveRuleset?.GeneralDiagnosticOption ?? ReportDiagnostic.Default, effectiveGeneralOption)) effectiveGeneralOption = effectiveRuleset.GeneralDiagnosticOption;
sabiendo que
IsStricterThan solo realiza una comparación; la condición puede reescribirse, por ejemplo, de esta manera:
if (effectiveRuleset == null || IsStricterThan(effectiveRuleset.GeneralDiagnosticOption, effectiveGeneralOption))
Pasemos al siguiente mensaje del analizador.
V3080 Posible desreferencia nula. Considere inspeccionar 'propertySymbol'. BinderFactory.BinderFactoryVisitor.cs 372 var propertySymbol = GetPropertySymbol(parent, resultBinder); var accessor = propertySymbol.GetMethod; if ((object)accessor != null) resultBinder = new InMethodBinder(accessor, resultBinder);
El uso posterior de la variable
propertySymbol debe tenerse en cuenta al corregir la advertencia del analizador.
private SourcePropertySymbol GetPropertySymbol( BasePropertyDeclarationSyntax basePropertyDeclarationSyntax, Binder outerBinder) { .... NamedTypeSymbol container = GetContainerType(outerBinder, basePropertyDeclarationSyntax); if ((object)container == null) return null; .... return (SourcePropertySymbol)GetMemberSymbol(propertyName, basePropertyDeclarationSyntax.Span, container, SymbolKind.Property); }
El método
GetMemberSymbol también puede devolver
nulo en algunos casos.
private Symbol GetMemberSymbol( string memberName, TextSpan memberSpan, NamedTypeSymbol container, SymbolKind kind) { foreach (Symbol sym in container.GetMembers(memberName)) { if (sym.Kind != kind) continue; if (sym.Kind == SymbolKind.Method) { .... var implementation = ((MethodSymbol)sym).PartialImplementationPart; if ((object)implementation != null) if (InSpan(implementation.Locations[0], this.syntaxTree, memberSpan)) return implementation; } else if (InSpan(sym.Locations, this.syntaxTree, memberSpan)) return sym; } return null; }
Usando un tipo de referencia anulable, la llamada cambiará así:
#nullable enable SourcePropertySymbol? propertySymbol = GetPropertySymbol(parent, resultBinder); MethodSymbol? accessor = propertySymbol?.GetMethod; if ((object)accessor != null) resultBinder = new InMethodBinder(accessor, resultBinder);
Bastante simple cuando sabes dónde solucionarlo. El análisis estático encuentra fácilmente este error potencial al obtener todos los valores de campo posibles en todas las cadenas de llamadas de procedimiento.
V3080 Posible desreferencia nula. Considere inspeccionar 'simpleName'. CSharpCommandLineParser.cs 1556 string simpleName; simpleName = PathUtilities.RemoveExtension( PathUtilities.GetFileName(sourceFiles.FirstOrDefault().Path)); outputFileName = simpleName + outputKind.GetDefaultExtension(); if (simpleName.Length == 0 && !outputKind.IsNetModule()) ....
El problema está en la línea con la comprobación de
simpleName.Length. simpleName es el resultado de una cadena completa de métodos y puede ser
nulo . Por cierto, por curiosidad, puede mirar el método
RemoveExtension y encontrar diferencias de
Path.GetFileNameWithoutExtension. Aquí podríamos limitarnos a verificar
simpleName! = Null , pero en el contexto de enlaces distintos de cero, el código se verá así:
#nullable enable public static string? RemoveExtension(string path) { .... } string simpleName;
La llamada se verá así:
simpleName = PathUtilities.RemoveExtension( PathUtilities.GetFileName(sourceFiles.FirstOrDefault().Path)) ?? String.Empty;
Conclusión
Los tipos de referencia anulables pueden ser de gran ayuda para planificar una arquitectura construida desde cero, pero la reelaboración del código existente puede requerir mucho tiempo y cuidado, ya que puede causar muchos errores sutiles. En este artículo, no teníamos el objetivo de desalentar a nadie de usar tipos de referencia anulables en nuestros proyectos. Creemos que esta innovación es generalmente útil para el lenguaje, aunque la forma en que se implementó puede generar dudas.
Siempre debe recordar las limitaciones inherentes a este enfoque, y que el modo de referencia anulable activado no protege contra errores con la eliminación de referencias de enlaces nulos, y si se usa incorrectamente, incluso puede conducir a ellos. Vale la pena considerar el uso de un analizador estático moderno, por ejemplo PVS-Studio, que admite el análisis interprocedial, como una herramienta adicional que, junto con la Referencia anulable, puede protegerlo de la desreferenciación de referencias nulas. Cada uno de estos enfoques, tanto el análisis interprocedural en profundidad como la anotación de firmas de métodos (que esencialmente hace la Referencia Anulable), tiene sus ventajas y desventajas. El analizador le permitirá obtener una lista de lugares potencialmente peligrosos y, al cambiar un código existente, ver todas las consecuencias de dichos cambios. Si asigna
nulo en cualquier caso, el analizador debe indicar inmediatamente a todos los consumidores la variable, donde no se verifica antes de desreferenciar.
Puede buscar independientemente otros errores, tanto en el proyecto considerado como en el suyo. Para hacer esto, solo necesita
descargar y probar el analizador PVS-Studio.

Si desea compartir este artículo con una audiencia de habla inglesa, utilice el enlace a la traducción: Paul Eremeev, Alexander Senichkin.
Tipos de referencia anulables en C # 8.0 y análisis estático