- Proposed
- Prototype: Not Started
- Implementation: Not Started
- Specification: Not Started
Allow developers to capture the expressions passed to a method, to enable better error messages in diagnostic/testing APIs and reduce keystrokes.
When an assertion or argument validation fails, the developer wants to know as much as possible about where and why it failed. However, today's diagnostic APIs do not fully facilitate this. Consider the following method:
T Single<T>(this T[] array)
{
Debug.Assert(array != null);
Debug.Assert(array.Length == 1);
return array[0];
}
When one of the asserts fail, only the filename, line number, and method name will be provided in the stack trace. The developer will not be able to tell which assert failed from this information-- (s)he will have to open the file and navigate to the provided line number to see what went wrong.
This is also the reason testing frameworks have to provide a variety of assert methods. With xUnit, Assert.True
and Assert.False
are not frequently used because they do not provide enough context about what failed.
While the situation is a bit better for argument validation because the names of invalid arguments are shown to the developer, the developer must pass these names to exceptions manually. If the above example were rewritten to use traditional argument validation instead of Debug.Assert
, it would look like
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];
}
Notice that nameof(array)
must be passed to each exception, although it's already clear from context which argument is invalid.
In the above examples, including the string "array != null"
or "array.Length == 1"
in the assert message would help the developer determine what failed. Enter CallerArgumentExpression
: it's an attribute the framework can use to obtain the string associated with a particular method argument. We would add it to Debug.Assert
like so
public static class Debug
{
public static void Assert(bool condition, [CallerArgumentExpression("condition")] string message = null);
}
The source code in the above example would stay the same. However, the code the compiler actually emits would correspond to
T Single<T>(this T[] array)
{
Debug.Assert(array != null, "array != null");
Debug.Assert(array.Length == 1, "array.Length == 1");
return array[0];
}
The compiler specially recognizes the attribute on Debug.Assert
. It passes the string associated with the argument referred to in the attribute's constructor (in this case, condition
) at the call site. When either assert fails, the developer will be shown the condition that was false and will know which one failed.
For argument validation, the attribute cannot be used directly, but can be made use of through a helper class:
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);
}
}
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];
}
T ElementAt(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];
}
A proposal to add such a helper class to the framework is underway at https://github.com/dotnet/corefx/issues/17068. If this language feature was implemented, the proposal could be updated to take advantage of this feature.
The this
parameter in an extension method may be referenced by CallerArgumentExpression
. For example:
public static void ShouldBe<T>(this T @this, T expected, [CallerArgumentExpression("this")] string thisExpression = null) {}
contestant.Points.ShouldBe(1337); // thisExpression: "contestant.Points"
thisExpression
will receive the expression corresponding to the object before the dot. If it's called with static method syntax, e.g. Ext.ShouldBe(contestant.Points, 1337)
, it will behave as if first parameter wasn't marked this
.
There should always be an expression corresponding to the this
parameter. Even if an instance of a class calls an extension method on itself, e.g. this.Single()
from inside a collection type, the this
is mandated by the compiler so "this"
will get passed. If this rule is changed in the future, we can consider passing null
or the empty string.
- Like the other
Caller*
attributes, such asCallerMemberName
, this attribute may only be used on parameters with default values. - Multiple parameters marked with
CallerArgumentExpression
are permitted, as shown above. - The attribute's namespace will be
System.Runtime.CompilerServices
. - If
null
or a string that is not a parameter name (e.g."notAParameterName"
) is provided, the compiler will pass in an empty string.
-
People who know how to use decompilers will be able to see some of the source code at call sites for methods marked with this attribute. This may be undesirable/unexpected for closed-source software.
-
Although this is not a flaw in the feature itself, a source of concern may be that there exists a
Debug.Assert
API today that only takes abool
. Even if the overload taking a message had its second parameter marked with this attribute and made optional, the compiler would still pick the no-message one in overload resolution. Therefore, the no-message overload would have to be removed to take advantage of this feature, which would be a binary (although not source) breaking change.
- If being able to see source code at call sites for methods that use this attribute proves to be a problem, we can make the attribute's effects opt-in. Developers will enable it through an assembly-wide
[assembly: EnableCallerArgumentExpression]
attribute they put inAssemblyInfo.cs
.- In the case the attribute's effects are not enabled, calling methods marked with the attribute would not be an error, to allow existing methods to use the attribute and maintain source compatibility. However, the attribute would be ignored and the method would be called with whatever default value was provided.
// 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")
- To prevent the binary compatibility problem from occurring every time we want to add new caller info to
Debug.Assert
, an alternative solution would be to add aCallerInfo
struct to the framework that contains all the necessary information about the caller.
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);
}
}
This was originally proposed at dotnet#87.
There are a few disadvantages of this approach:
-
Despite being pay-for-play friendly by allowing you to specify which properties you need, it could still hurt perf significantly by allocating an array for the expressions/calling
MethodBase.GetCurrentMethod
even when the assert passes. -
Additionally, while passing a new flag to the
CallerInfo
attribute won't be a breaking change,Debug.Assert
won't be guaranteed to actually receive that new parameter from call sites that compiled against an old version of the method.
TBD
N/A