259 lines
11 KiB
Markdown
Executable file
259 lines
11 KiB
Markdown
Executable file
# CallerArgumentExpression
|
|
|
|
* [x] Proposed
|
|
* [ ] Prototype: Not Started
|
|
* [ ] Implementation: Not Started
|
|
* [ ] Specification: Not Started
|
|
|
|
## Summary
|
|
[summary]: #summary
|
|
|
|
Allow developers to capture the expressions passed to a method, to enable better error messages in diagnostic/testing APIs and reduce keystrokes.
|
|
|
|
## Motivation
|
|
[motivation]: #motivation
|
|
|
|
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:
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
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.
|
|
|
|
## Detailed design
|
|
[design]: #detailed-design
|
|
|
|
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
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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.
|
|
|
|
### Extension methods
|
|
|
|
The `this` parameter in an extension method may be referenced by `CallerArgumentExpression`. For example:
|
|
|
|
```csharp
|
|
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.
|
|
|
|
### Extra details
|
|
|
|
- Like the other `Caller*` attributes, such as `CallerMemberName`, 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.
|
|
|
|
## Drawbacks
|
|
[drawbacks]: #drawbacks
|
|
|
|
- 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 a `bool`. 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.
|
|
|
|
## Alternatives
|
|
[alternatives]: #alternatives
|
|
|
|
- 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 in `AssemblyInfo.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.
|
|
|
|
```csharp
|
|
// 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][drawbacks] from occurring every time we want to add new caller info to `Debug.Assert`, an alternative solution would be to add a `CallerInfo` struct to the framework that contains all the necessary information about the caller.
|
|
|
|
```csharp
|
|
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 https://github.com/dotnet/csharplang/issues/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.
|
|
|
|
## Unresolved questions
|
|
[unresolved]: #unresolved-questions
|
|
|
|
TBD
|
|
|
|
## Design meetings
|
|
|
|
N/A
|