CallerArgumentExpression
Nota
Este artículo es una especificación de características. La especificación actúa como documento de diseño de la característica. Incluye cambios de especificación propuestos, junto con la información necesaria durante el diseño y el desarrollo de la característica. Estos artículos se publican hasta que se finalizan los cambios de especificación propuestos e se incorporan en la especificación ECMA actual.
Puede haber algunas discrepancias entre la especificación de características y la implementación completada. Esas diferencias se recogen en las notas de la reunión de diseño de lenguaje (LDM) correspondientes.
Puede obtener más información sobre el proceso de adopción de especificaciones de características en el estándar del lenguaje C# en el artículo sobre las especificaciones de .
Problema planteado por el experto: https://github.com/dotnet/csharplang/issues/287
Resumen
Permitir que los desarrolladores capturen las expresiones pasadas a un método para habilitar mejores mensajes de error en las API de diagnóstico y pruebas y reducir las pulsaciones de tecla.
Motivación
Cuando se produce un error en la validación de una aserción o argumento, el desarrollador quiere saber tanto como sea posible sobre dónde y por qué se produjo un error. Sin embargo, las API de diagnóstico actuales no facilitan completamente esto. Tenga en cuenta el método siguiente:
T Single<T>(this T[] array)
{
Debug.Assert(array != null);
Debug.Assert(array.Length == 1);
return array[0];
}
Cuando se produce un error en una de las aserciones, solo se proporcionará el nombre de archivo, el número de línea y el nombre del método en el seguimiento de la pila. El desarrollador no podrá saber qué aserción produjo un error en esta información: tendrá que abrir el archivo y navegar hasta el número de línea proporcionado para ver lo que salió mal.
Este es también el motivo por el que los marcos de pruebas tienen que proporcionar una variedad de métodos de aserción. Con xUnit, Assert.True
y Assert.False
no se usan con frecuencia porque no proporcionan suficiente contexto sobre lo que produjo un error.
Aunque la situación es un poco mejor para la validación de argumentos porque los nombres de argumentos no válidos se muestran al desarrollador, el desarrollador debe pasar estos nombres a excepciones manualmente. Si el ejemplo anterior se reescribiera para usar la validación de argumentos tradicional en lugar de Debug.Assert
, tendría el aspecto siguiente:
T Single<T>(this T[] array)
{
if (array == null)
{
throw new ArgumentNullException(nameof(array));
}
if (array.Length != 1)
{
throw new ArgumentException("Array must contain a single element.", nameof(array));
}
return array[0];
}
Tenga en cuenta que nameof(array)
debe pasarse a cada una de las excepciones, aunque ya está claro por el contexto qué argumento no es válido.
Diseño detallado
En los ejemplos anteriores, incluida la cadena "array != null"
o "array.Length == 1"
en el mensaje de aserción ayudaría al desarrollador a determinar lo que produjo un error. Escriba CallerArgumentExpression
: es un atributo que el marco puede usar para obtener la cadena asociada a un argumento de método determinado. Lo agregaríamos a Debug.Assert
así
public static class Debug
{
public static void Assert(bool condition, [CallerArgumentExpression("condition")] string message = null);
}
El código fuente del ejemplo anterior sería el mismo. Sin embargo, el código que el compilador realmente emite se correspondería con
T Single<T>(this T[] array)
{
Debug.Assert(array != null, "array != null");
Debug.Assert(array.Length == 1, "array.Length == 1");
return array[0];
}
El compilador reconoce especialmente el atributo en Debug.Assert
. Pasa la cadena asociada al argumento al que se hace referencia en el constructor del atributo (en este caso, condition
) en el sitio de llamada. Cuando alguna de las aserciones falla, se mostrará al desarrollador la condición falsa y sabrá cuál de ellas ha fallado.
Para la validación de argumentos, el atributo no se puede usar directamente, pero se puede usar a través de una clase auxiliar:
public static class Verify
{
public static void Argument(bool condition, string message, [CallerArgumentExpression("condition")] string conditionExpression = null)
{
if (!condition) throw new ArgumentException(message: message, paramName: conditionExpression);
}
public static void InRange(int argument, int low, int high,
[CallerArgumentExpression("argument")] string argumentExpression = null,
[CallerArgumentExpression("low")] string lowExpression = null,
[CallerArgumentExpression("high")] string highExpression = null)
{
if (argument < low)
{
throw new ArgumentOutOfRangeException(paramName: argumentExpression,
message: $"{argumentExpression} ({argument}) cannot be less than {lowExpression} ({low}).");
}
if (argument > high)
{
throw new ArgumentOutOfRangeException(paramName: argumentExpression,
message: $"{argumentExpression} ({argument}) cannot be greater than {highExpression} ({high}).");
}
}
public static void NotNull<T>(T argument, [CallerArgumentExpression("argument")] string argumentExpression = null)
where T : class
{
if (argument == null) throw new ArgumentNullException(paramName: argumentExpression);
}
}
static T Single<T>(this T[] array)
{
Verify.NotNull(array); // paramName: "array"
Verify.Argument(array.Length == 1, "Array must contain a single element."); // paramName: "array.Length == 1"
return array[0];
}
static T ElementAt<T>(this T[] array, int index)
{
Verify.NotNull(array); // paramName: "array"
// paramName: "index"
// message: "index (-1) cannot be less than 0 (0).", or
// "index (6) cannot be greater than array.Length - 1 (5)."
Verify.InRange(index, 0, array.Length - 1);
return array[index];
}
Se está llevando a cabo una propuesta para agregar dicha clase auxiliar al marco en https://github.com/dotnet/corefx/issues/17068. Si se implementó esta característica de lenguaje, la propuesta podría actualizarse para aprovechar esta característica.
Métodos de extensión
El parámetro this
de un método de extensión puede ser referenciado por CallerArgumentExpression
. Por ejemplo:
public static void ShouldBe<T>(this T @this, T expected, [CallerArgumentExpression("this")] string thisExpression = null) {}
contestant.Points.ShouldBe(1337); // thisExpression: "contestant.Points"
thisExpression
recibirá la expresión correspondiente al objeto antes del punto. Si se llama a con sintaxis de método estático, por ejemplo, Ext.ShouldBe(contestant.Points, 1337)
, se comportará como si el primer parámetro no estuviera marcado this
.
Siempre debe haber una expresión correspondiente al parámetro this
. Incluso si una instancia de una clase llama a un método de extensión sobre sí mismo, por ejemplo, this.Single()
, desde dentro de un tipo de colección, el compilador exige que this
sea necesario, de modo que "this"
se pasará. Si esta regla se cambia en el futuro, podemos considerar la posibilidad de pasar null
o la cadena vacía.
Detalles adicionales
- Al igual que los demás atributos de
Caller*
, comoCallerMemberName
, este atributo solo se puede usar en parámetros con valores predeterminados. - Se permiten varios parámetros marcados con
CallerArgumentExpression
, como se muestra anteriormente. - El espacio de nombres del atributo será
System.Runtime.CompilerServices
. - Si se proporciona
null
o una cadena que no es un nombre de parámetro (por ejemplo,"notAParameterName"
), el compilador pasará una cadena vacía. - El tipo al que se aplica el parámetro
CallerArgumentExpressionAttribute
debe tener una conversión estándar desdestring
. Esto significa que no se permiten conversiones definidas por el usuario destring
y, en la práctica, significa que el tipo de dicho parámetro debe serstring
,object
o una interfaz implementada porstring
.
Inconvenientes
Las personas que saben cómo usar descompiladores podrán ver parte del código fuente en los sitios de llamada para los métodos marcados con este atributo. Esto puede no ser deseable o inesperado para el software de código cerrado.
Aunque esto no es un defecto de la característica, una fuente de preocupación puede ser que actualmente existe una API de
Debug.Assert
que solo toma unbool
. Incluso si la sobrecarga que toma un mensaje tuviera su segundo parámetro marcado con este atributo y fuera opcional, el compilador seguiría eligiendo la que no toma mensaje en la resolución de la sobrecarga. Por lo tanto, la sobrecarga que no toma mensaje se tendría que eliminar para aprovechar esta característica, lo que sería un cambio binario importante (aunque no de código fuente).
Alternativas
- Si ver el código fuente en los sitios de llamada de los métodos que utilizan este atributo resulta problemático, podemos hacer que los efectos del atributo sean opcionales. Los desarrolladores lo habilitarán a través de un atributo
[assembly: EnableCallerArgumentExpression]
en todo el ensamblado que colocarán enAssemblyInfo.cs
.- En caso de que los efectos del atributo no estén habilitados, llamar a métodos marcados con el atributo no sería un error, para permitir que los métodos existentes usen el atributo y mantengan la compatibilidad de origen. Sin embargo, se omitiría el atributo y se llamaría al método con cualquier valor predeterminado proporcionado.
// Assembly1
void Foo(string bar); // V1
void Foo(string bar, string barExpression = "not provided"); // V2
void Foo(string bar, [CallerArgumentExpression("bar")] string barExpression = "not provided"); // V3
// Assembly2
Foo(a); // V1: Compiles to Foo(a), V2, V3: Compiles to Foo(a, "not provided")
Foo(a, "provided"); // V2, V3: Compiles to Foo(a, "provided")
// Assembly3
[assembly: EnableCallerArgumentExpression]
Foo(a); // V1: Compiles to Foo(a), V2: Compiles to Foo(a, "not provided"), V3: Compiles to Foo(a, "a")
Foo(a, "provided"); // V2, V3: Compiles to Foo(a, "provided")
- Para evitar que el problema de compatibilidad binaria de se produzca cada vez que queremos agregar información del autor de la llamada a
Debug.Assert
, una solución alternativa sería agregar una estructura deCallerInfo
al marco que contiene toda la información necesaria sobre el autor de la llamada.
struct CallerInfo
{
public string MemberName { get; set; }
public string TypeName { get; set; }
public string Namespace { get; set; }
public string FullTypeName { get; set; }
public string FilePath { get; set; }
public int LineNumber { get; set; }
public int ColumnNumber { get; set; }
public Type Type { get; set; }
public MethodBase Method { get; set; }
public string[] ArgumentExpressions { get; set; }
}
[Flags]
enum CallerInfoOptions
{
MemberName = 1, TypeName = 2, ...
}
public static class Debug
{
public static void Assert(bool condition,
// If a flag is not set here, the corresponding CallerInfo member is not populated by the caller, so it's
// pay-for-play friendly.
[CallerInfo(CallerInfoOptions.FilePath | CallerInfoOptions.Method | CallerInfoOptions.ArgumentExpressions)] CallerInfo callerInfo = default(CallerInfo))
{
string filePath = callerInfo.FilePath;
MethodBase method = callerInfo.Method;
string conditionExpression = callerInfo.ArgumentExpressions[0];
//...
}
}
class Bar
{
void Foo()
{
Debug.Assert(false);
// Translates to:
var callerInfo = new CallerInfo();
callerInfo.FilePath = @"C:\Bar.cs";
callerInfo.Method = MethodBase.GetCurrentMethod();
callerInfo.ArgumentExpressions = new string[] { "false" };
Debug.Assert(false, callerInfo);
}
}
Esto se propuso originalmente en https://github.com/dotnet/csharplang/issues/87.
Hay algunas desventajas de este enfoque:
A pesar de ser apto para el método de "pay-for-play" al permitirle especificar qué propiedades necesita, podría seguir afectando significativamente al rendimiento al asignar una matriz para las expresiones o al llamar a
MethodBase.GetCurrentMethod
incluso cuando se cumplen las aserciones.Además, pasar una nueva marca al atributo
CallerInfo
no será un cambio importante. No se garantiza queDebug.Assert
realmente reciba ese nuevo parámetro desde los puntos de llamada que se compilaron respecto a una versión anterior del método.
Preguntas sin resolver
TBD
Reuniones de diseño
N/A
C# feature specifications