
No es un secreto que Microsoft ha estado trabajando en la octava versión del lenguaje C # durante bastante tiempo. La nueva versión del lenguaje (C # 8.0) ya está disponible en la versión reciente de Visual Studio 2019, pero aún está en versión beta. Esta nueva versión tendrá algunas características implementadas de una manera algo no obvia, o más bien inesperada. Los tipos de referencia anulables son uno de ellos. Esta característica se anuncia como un medio para combatir las Excepciones de referencia nula (NRE).
Es bueno ver evolucionar el lenguaje y adquirir nuevas funciones para ayudar a los desarrolladores. Por coincidencia, hace algún tiempo, mejoramos significativamente la capacidad del analizador C # de PVS-Studio para detectar NRE. Y ahora nos preguntamos si los analizadores estáticos en general y PVS-Studio en particular aún deberían molestarse en diagnosticar posibles desreferencias nulas ya que, al menos en el nuevo código que hará uso de Nullable Reference, esas desreferencias serán "imposibles". Tratemos de aclarar eso.
Pros y contras de la nueva característica
Un recordatorio antes de continuar: la última versión beta de C # 8.0, disponible al momento de escribir esta publicación, tiene los tipos de referencia anulables deshabilitados de forma predeterminada, es decir, el comportamiento de los tipos de referencia no ha cambiado.
Entonces, ¿qué son exactamente los tipos de referencia anulables en C # 8.0 si habilitamos esta opción? Básicamente son los mismos tipos de referencia antiguos, excepto que ahora tendrá que agregar '?' después del nombre del tipo (por ejemplo,
cadena? ), de manera similar a
Nullable <T> , es decir, tipos de valores que aceptan valores NULL (por ejemplo,
int? ). Sin el '?', Nuestro tipo de
cadena ahora se interpretará como referencia no anulable, es decir, un tipo de referencia que no puede asignarse como
nulo .
La excepción de referencia nula es una de las excepciones más irritantes para entrar en su programa porque no dice mucho sobre su origen, especialmente si el método de lanzamiento contiene una serie de operaciones de desreferencia en una fila. La capacidad de prohibir la asignación nula a una variable de un tipo de referencia parece genial, pero ¿qué pasa con los casos en que pasar un
nulo a un método tiene cierta lógica de ejecución dependiendo de ello? En lugar de
nulo , podríamos, por supuesto, usar un valor literal, constante o simplemente "imposible" que lógicamente no se puede asignar a la variable en ningún otro lugar. Pero esto plantea el riesgo de reemplazar un bloqueo del programa con una ejecución "silenciosa" pero incorrecta, que a menudo es peor que enfrentar el error de inmediato.
¿Qué hay de lanzar una excepción entonces? Una excepción significativa lanzada en un lugar donde algo salió mal siempre es mejor que una
NRE en algún lugar arriba o abajo de la pila. Pero solo es bueno en su propio proyecto, donde puede corregir a los consumidores insertando un bloque
try-catch y es su exclusiva responsabilidad. Cuando se desarrolla una biblioteca utilizando (no) referencia anulable, debemos garantizar que un determinado método siempre devuelva un valor. Después de todo, no siempre es posible (o al menos fácil), incluso en su propio código, reemplazar la devolución de
nulo con lanzamiento de excepción (ya que puede afectar demasiado código).
Nullable Reference puede habilitarse a nivel de proyecto global agregando la propiedad
NullableContextOptions con el valor
enable, o en el nivel de archivo mediante la directiva de preprocesador:
#nullable enable string cantBeNull = string.Empty; string? canBeNull = null; cantBeNull = canBeNull!;
La función de referencia anulable hará que los tipos sean más informativos. La firma del método le da una pista sobre su comportamiento: si tiene una verificación nula o no, si puede devolver
nula o no. Ahora, cuando intente usar una variable de referencia anulable sin verificarla, el compilador emitirá una advertencia.
Esto es bastante conveniente cuando se usan bibliotecas de terceros, pero también agrega un riesgo de engañar al usuario de la biblioteca, ya que todavía es posible pasar
nulo usando el nuevo operador que perdona nulos (!). Es decir, agregar solo un signo de exclamación puede romper todos los supuestos adicionales sobre la interfaz usando tales variables:
#nullable enable String GetStr() { return _count > 0 ? _str : null!; } String str = GetStr(); var len = str.Length;
Sí, puede argumentar que se trata de una mala programación y que nadie escribiría un código así de verdad, pero mientras esto se pueda hacer, no puede sentirse seguro confiando solo en el contrato impuesto por la interfaz de un método dado ( diciendo que no puede devolver
nulo ).
Por cierto, ¡podrías escribir el mismo código usando varios
! operadores, ya que C # ahora le permite hacerlo (y dicho código es perfectamente compilable):
cantBeNull = canBeNull!!!!!!!;
Al escribir de esta manera, por así decirlo, enfatizamos la idea, "¡mira, esto puede ser
nulo !" (nosotros en nuestro equipo, llamamos a esto programación "emocional"). De hecho, al construir el árbol de sintaxis, el compilador (de Roslyn) interpreta el
! operador de la misma manera que interpreta los paréntesis regulares, lo que significa que puede escribir tantos
! es como quieras, como con paréntesis. Pero si escribe lo suficiente, puede "derribar" el compilador. Tal vez esto se arregle en la versión final de C # 8.0.
Del mismo modo, puede eludir la advertencia del compilador al acceder a una variable de referencia anulable sin una verificación:
canBeNull!.ToString();
Agreguemos más emociones:
canBeNull!!!?.ToString();
Sin embargo, casi nunca verá una sintaxis como esa en el código real. Al escribir el operador
indulgente le decimos al compilador: "Este código está bien, no es necesario verificarlo". Al agregar el operador de Elvis, le decimos: “O tal vez no; vamos a verlo por si acaso ".
Ahora, puede preguntarse razonablemente por qué todavía puede tener
nulo asignado a variables de tipos de referencia no anulables tan fácilmente si el concepto mismo de este tipo implica que tales variables no pueden tener el valor
nulo . La respuesta es que "bajo el capó", en el nivel del código IL, nuestro tipo de referencia no anulable sigue siendo ... el viejo tipo de referencia "regular", y toda la sintaxis de nulabilidad es en realidad solo una anotación para el compilador incorporado analizador (que, creemos, no es muy conveniente de usar, pero lo explicaré más adelante). Personalmente, no encontramos una solución "ordenada" para incluir la nueva sintaxis como simplemente una anotación para una herramienta de terceros (incluso integrada en el compilador) porque el hecho de que esto sea solo una anotación puede no ser obvio en absoluto para el programador, ya que esta sintaxis es muy similar a la sintaxis para estructuras anulables pero funciona de una manera totalmente diferente.
Volviendo a otras formas de romper los tipos de referencia anulables. En el momento de escribir este artículo, cuando tiene una solución compuesta por varios proyectos, pasando una variable de un tipo de referencia, digamos,
Cadena de un método declarado en un proyecto a un método en otro proyecto que tiene el
NullableContext el compilador asume que se trata de una cadena no anulable y el compilador permanecerá en silencio. Y eso a pesar de las toneladas de
atributos [Nullable (1)] agregados a cada campo y método en el código IL cuando se habilitan las referencias Nullable
. Estos atributos, por cierto, deben tenerse en cuenta si usa la reflexión para manejar los atributos y supone que el código contiene solo los personalizados.
Tal situación puede causar problemas adicionales al adaptar una base de código grande al estilo de referencia anulable. Es probable que este proceso se ejecute por un tiempo, proyecto por proyecto. Si tiene cuidado, por supuesto, puede integrar gradualmente la nueva característica, pero si ya tiene un proyecto en funcionamiento, cualquier cambio en él es peligroso e indeseable (si funciona, ¡no lo toque!). Es por eso que nos aseguramos de que no tenga que modificar su código fuente o marcarlo para detectar
NRE potenciales al usar el analizador PVS-Studio. Para verificar ubicaciones que podrían
generar una
NullReferenceException, simplemente ejecute el analizador y busque 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 el código heredado.
Al agregar soporte de referencia anulable a PVS-Studio, tuvimos que decidir si el analizador debe suponer que las variables de tipos de referencia no anulables siempre tienen valores no nulos. Después de investigar las formas en que esta garantía podría romperse, decidimos que PVS-Studio no debería hacer tal suposición. Después de todo, incluso si un proyecto utiliza tipos de referencia no anulables en todo momento, el analizador podría agregar a esta característica al detectar esas situaciones específicas en las que dichas variables podrían tener el valor
nulo .
Cómo se ve PVS-Studio para excepciones de referencia nula
Los mecanismos de flujo de datos en el analizador C # de PVS-Studio rastrean posibles valores de variables durante el proceso de análisis. Esto también incluye análisis interprocedimiento, es decir, rastrear posibles valores devueltos por un método y sus métodos anidados, y así sucesivamente. Además de eso, PVS-Studio recuerda variables a las que se les puede asignar un valor
nulo . Siempre que vea que dicha variable se desreferencia sin una verificación, ya sea en el código actual bajo análisis o dentro de un método invocado en este código, emitirá una advertencia V3080 sobre una posible excepción de referencia nula.
La idea detrás de este diagnóstico es hacer que el analizador se enoje solo cuando vea una asignación
nula . Esta es la principal diferencia entre el comportamiento de nuestro diagnóstico y el del analizador integrado del compilador que maneja los tipos de referencia anulables. El analizador incorporado apuntará a todas y cada una de las referencias de una variable de referencia anulable no verificada, dado que no ha sido engañada por el uso de
! operador o incluso simplemente una comprobación complicada (sin embargo, debe tenerse en cuenta que absolutamente cualquier analizador estático, PVS-Studio no es una excepción aquí, puede ser "engañado" de una forma u otra, especialmente si tiene la intención de hacerlo).
PVS-Studio, por otro lado, solo le advierte si ve un
valor nulo (ya sea dentro del contexto local o el contexto de un método externo). Incluso si la variable es de un tipo de referencia no anulable, el analizador seguirá apuntándola si ve una asignación
nula a esa variable. Creemos que este enfoque es más apropiado (o al menos más conveniente para el usuario) ya que no exige "difuminar" todo el código con comprobaciones nulas para rastrear posibles desreferencias; después de todo, esta opción estaba disponible incluso antes de la Referencia Nulable se introdujeron, por ejemplo, mediante el uso de contratos. Además, el analizador ahora puede proporcionar un mejor control sobre las variables de referencia no anulables. Si dicha variable se usa "bastante" y nunca se le asigna
nulo , PVS-Studio no dirá una palabra. Si la variable se asigna
nula y luego se desreferencia sin una verificación previa, PVS-Studio emitirá una advertencia V3080:
#nullable enable String GetStr() { return _count > 0 ? _str : null!; } String str = GetStr(); var len = str.Length; <== V3080: Possible null dereference. Consider inspecting 'str'
Ahora echemos un vistazo a algunos ejemplos que demuestran cómo este diagnóstico es activado por el código de Roslyn. Ya
revisamos este proyecto recientemente, pero esta vez solo veremos las posibles excepciones de referencia nula no mencionadas en los artículos anteriores. Veremos cómo PVS-Studio detecta los NRE potenciales y cómo se pueden solucionar con 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, a la variable
chainedTupleType se le puede asignar el valor
nulo en una de las ramas de ejecución. Luego se pasa al método
ConstructTupleUnderlyingType y se usa allí después de una verificación
Debug.Assert . Es un patrón muy común en Roslyn, pero tenga en cuenta que
Debug.Assert se elimina en la versión de lanzamiento. Es por eso que el analizador todavía considera peligrosa la desreferencia dentro del método
ConstructTupleUnderlyingType . Aquí está el cuerpo de ese método, donde tiene lugar 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; }
En realidad, es una cuestión de controversia si el analizador debe tener en cuenta Activos como ese (algunos de nuestros usuarios quieren que lo haga); después de todo, el analizador toma los contratos de System.Diagnostics.Contracts en cuenta. Aquí hay un pequeño ejemplo de la vida real de nuestra experiencia de usar Roslyn en nuestro propio analizador. Mientras
agregamos compatibilidad con la última versión de Visual Studio recientemente, también actualizamos Roslyn a su tercera versión. Después de eso, PVS-Studio comenzó a fallar en cierto código que nunca antes se había bloqueado. El bloqueo, acompañado de una excepción de referencia nula, ocurriría no en nuestro código sino en el código de Roslyn. La depuración reveló que el fragmento de código donde Roslyn se estaba bloqueando tenía ese tipo de
depuración. Verificación nula basada en
Assert varias líneas más arriba, y esa verificación obviamente no ayudó.
Es un ejemplo gráfico de cómo puede meterse en problemas con Nullable Reference debido a que el compilador trata
Debug.Assert como una verificación confiable en cualquier configuración. Es decir, si agrega
#nullable enable y marca el argumento
chainedTupleTypeOpt como referencia anulable
, el compilador no emitirá ninguna advertencia sobre la desreferencia dentro del método
ConstructTupleUnderlyingType .
Pasando a otros ejemplos de advertencias 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 dice que la llamada al método
WithEffectiveAction puede devolver
nulo , mientras que el valor de retorno asignado a la variable
efectivoRuleset no se verifica antes de su uso (
efectivoRuleset.GeneralDiagnosticOption ). Aquí está el cuerpo del método
WithEffectiveAction :
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; } }
Con Nullable Reference habilitado para el método
GetEffectiveRuleSet , obtendremos dos ubicaciones en las que se debe cambiar el comportamiento del código. Dado que el método que se muestra arriba puede lanzar una excepción, es lógico suponer que la llamada está envuelta en un bloque
try-catch y sería correcto reescribir el método para lanzar una excepción en lugar de devolver
nulo . Sin embargo, si rastrea algunas llamadas, verá que el código de captura está demasiado lejos para predecir de manera confiable las consecuencias. Echemos un vistazo al consumidor de la variable
efectivaRuleset , el 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, es una simple declaración de cambio que elige entre dos enumeraciones, con
ReportDiagnostic.Default como valor predeterminado. Por lo tanto, sería mejor reescribir la llamada de la siguiente manera:
La firma de
WithEffectiveAction cambiará:
#nullable enable public RuleSet? WithEffectiveAction(ReportDiagnostic action)
Así se verá la llamada:
RuleSet? effectiveRuleset = ruleSet.GetEffectiveRuleSet(includedRulesetPaths); effectiveRuleset = effectiveRuleset?.WithEffectiveAction(ruleSetInclude.Action); if (IsStricterThan(effectiveRuleset?.GeneralDiagnosticOption ?? ReportDiagnostic.Default, effectiveGeneralOption)) effectiveGeneralOption = effectiveRuleset.GeneralDiagnosticOption;
Dado que
IsStricterThan solo realiza una comparación, la condición puede reescribirse, por ejemplo, de esta manera:
if (effectiveRuleset == null || IsStricterThan(effectiveRuleset.GeneralDiagnosticOption, effectiveGeneralOption))
Siguiente ejemplo
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);
Para corregir esta advertencia, necesitamos ver qué sucede con la variable
propertySymbol a continuación.
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 bajo ciertas condiciones.
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; }
Con los tipos de referencia anulables habilitados, la llamada cambiará a esto:
#nullable enable SourcePropertySymbol? propertySymbol = GetPropertySymbol(parent, resultBinder); MethodSymbol? accessor = propertySymbol?.GetMethod; if ((object)accessor != null) resultBinder = new InMethodBinder(accessor, resultBinder);
Es bastante fácil de arreglar cuando sabes dónde buscar. El análisis estático puede detectar este error potencial sin esfuerzo al recopilar todos los valores posibles del campo de 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 verificación
simpleName.Length . La variable
simpleName resulta de ejecutar una larga serie de métodos y puede asignarse
nulo . Por cierto, si tiene curiosidad, puede mirar el método
RemoveExtension para ver cómo es diferente de
Path.GetFileNameWithoutExtension. Un
simpleName! = Comprobación
nula sería suficiente, pero con tipos de referencia no anulables, el código cambiará a algo como esto:
#nullable enable public static string? RemoveExtension(string path) { .... } string simpleName;
Así es como se vería la llamada:
simpleName = PathUtilities.RemoveExtension( PathUtilities.GetFileName(sourceFiles.FirstOrDefault().Path)) ?? String.Empty;
Conclusión
Los tipos de referencia anulables pueden ser de gran ayuda cuando se diseña arquitectura desde cero, pero la reelaboración del código existente puede requerir mucho tiempo y cuidado, ya que puede conducir a una serie de errores difíciles de alcanzar. Este artículo no tiene como objetivo desalentarlo de usar tipos de referencia anulables. Consideramos que esta nueva característica generalmente es útil, aunque la forma exacta en que se implementa puede ser controvertida.
Sin embargo, recuerde siempre las limitaciones de este enfoque y tenga en cuenta que habilitar el modo de referencia anulable no lo protege de las NRE y que, si se usa incorrectamente, podría convertirse en la fuente de estos errores. Recomendamos que complemente la función de referencia anulable con una herramienta de análisis estático moderna, como PVS-Studio, que admite análisis interprocedimiento para proteger su programa de NRE. Cada uno de estos enfoques (análisis interprocedural profundo y firmas de métodos de anotación (que es, de hecho, lo que hace el modo de referencia anulable)) tiene sus ventajas y desventajas. El analizador le proporcionará una lista de ubicaciones potencialmente peligrosas y le permitirá ver las consecuencias de modificar el código existente. Si hay una asignación nula en alguna parte, el analizador apuntará a cada consumidor de la variable donde se desreferencia sin una verificación.
Puede verificar este proyecto o sus propios proyectos en busca de otros defectos: simplemente
descargue PVS-Studio y pruébelo.