Programming Concept: C# Expressions - part 2

Posted On: 2021-03-01

By Mark

Today's post is the second part in a series about C# expressions. Last week, I'd explained what Expressions are, and this week I'll dive into the details of how and when to use them.

A Simple Example

In order to understand how to use expressions, I'll start with an example that is too simple to be practical - a "Hello World", if you will. Consider a method that allows one to express a message:

var example = new { Message = "Hello there!" };
HelloWorld( () => example.Message);
Since the message is always a string, one could start with a method signature that enforces that, say:
public string HelloWorld(Expression<Func<string>> messageExpression)
This will provide the flexibility that any lambda expression can be passed in, so long as it returns a string. To accommodate that, the method will likely need to inspect the lambda, in order to determine how best to proceed. There are several options for how to do so, but, for simplicity, I'll use the as operator together with null checks:

string source = null;
var propertyMessage = messageExpression.Body as MemberExpression;
if( propertyMessage !=null) {
    //Get the name of the property
    source = propertyMessage.Member.Name;
}
//Other types omitted for brevity

if( source != null) {
    //Execute the lambda expression to get the message content (must be compiled first)
    var message = messageExpression.Compile().Invoke();
    return $"{source} says \"{message}\"");
}
//Error handling omitted for brevity

In the above example, the type of expression is detected (using the "as" operator), and then, based on the kind of expression, a different string is returned. Importantly, this must be implemented for every kind of expression (ie. what's literally in that example only works with properties/fields - which both are represented using the MemberExpression type). If, for example, you wanted to support passing in a constant:

HelloWorld( () => "Hello there!" )
Then you'd need to include handling for the ConstantExpressiontype.

In addition to those limitations, this also only accounts for the final pair of nodes on the binary expression tree. For example, it would work as expected with the prior example:

var example = new { Message = "Hello there!" };
HelloWorld( () => example.Message );
//returns "Message says "Hello there!""
but if you feed it a more deeply nested object, don't expect it to return the whole hierarchy:
var example = new { MessageObject = { InteriorMessage = "Hello there!" } };
HelloWorld( () => example.MessageObject.InteriorMessage );
//returns "InteriorMessage says "Hello there!"" 
//(it does NOT return "MessageObject.InteriorMessage says "Hello there!")

A Real-world Example

All of the issues with my simple example can be addressed with increased complexity and better design. Microsoft's .Net MVC framework, for example, provides Html Helpers that can turn a C# model into Html controls (such as inputs), no matter how complex or awkwardly shaped the model is. To achieve this, the entire hierarchy of the lambda needs to be stored as a string - including both member and index accessors. To facilitate this, the framework has an ExpressionHelper class.

Looking over the ExpressionHelper code, one can see that it follows a similar pattern to my simplistic example earlier. In GetExpressionText, they detect which kind of logic to use (based on NodeType property) and execute different logic accordingly. Notably different, however, is that the logic is designed to be loopable: as each expression in the binary expression tree is resolved, the results are stored (pushed onto a stack), and the next part of the tree is fed back through the loop. Once every expression in the tree is resolved, the results are then merged together (using Aggregate) to produce an accurate representation of the entire tree. Thus, MVC is capable of accurately storing model accessors in their entirety:

Expression<Func<string>> candleCountDelegate = () => Model.Birthday[10].Cake.CandlesCount;
GetExpressionText(candleCountDelegate)
//returns Birthday[10].Cake.CandlesCount

Practical Applications

As the previous example shows, a common practical application of Expressions is when generating code for a different language. MVC converts C# accessors into string keys for Html code, and several lambda-focused ORM libraries (such as Linq-to-SQL) use Expressions to enable developers to write type-safe queries (which it then turns into the appropriate SQL commands). Personally, I've both consumed and developed* libraries that use expressions in this way - and it's the only use-case I have personally witnessed.

Besides bridging between languages, there are several other potential real-world applications for expressions. The ability to read and modify runtime instructions can potentially be useful for proxying. It can also be useful for injecting additional instructions - potentially for developer productivity or other forms of runtime automation. One could even use expressions to convert a scripting language (such as a DSL) into executable C# code.

Impractical Applications

One of the things that drew me to Expressions in the first place is how incredibly powerful they are. They've let me explore some truly absurd* approaches to problems - as there's little (if any) theoretical limit on what can be achieved. One could, for example, mutate the implementation of functions over the course of an application's run - effectively turning the function definition itself into a form of application state. If you're interested in learning Expressions, exploring the the wilder possibilities could be a great place to start - just, please, don't actually ship anything like that (it would no doubt be a maintenance nightmare - even before considering what it might mean for the system's security.)

Parting Thoughts

Though most people seldom use them, Expressions have been - and continue to be - a massively important part of the C# and .Net ecosystem. Personally, understanding Expressions played a key role in advancing my skills as a developer - since that knowledge later became the foundation for my understanding how to author and consume lambdas (amongst other things). It is my hope that what I've written can contribute to your journey of understanding as well - no matter where you are in it. As always, if you have any thoughts or feedback, please let me know.