The Expression Tree feature added in. Net 3.5 introduces the concept of "logic as data" in. NET platform for the first time. In other words, we canCodeIn advanced languages, however, this logic is stored as data. Because of this, we can use different methods to process it. For example, you can convert it into an SQL query or an external service call. This is one of the important cornerstones of the technical implementation of LINQ to everything.
Realistically speaking, the expression tree in. Net 3.5 has limited capabilities and can only be used to represent one "expression", rather than "statement ". That is to say, we can use it to represent a "method call" or "attribute access", but it cannot be used to represent a "logic ". However, Microsoft has enhanced this feature in. Net 4.0. In. Net 4.0, we can use the expression tree to construct a logic with "variable Declaration", "judgment", and "loop. When "logic" becomes "data", we have a broader space to exert our creativity. For example, we can convert a sequence logic written in C # into client JavaScript code containing asynchronous calls to quickly build a web application with complex client logic.Program.
However, even the "half-hanging" feature of the Expression Tree in. Net 3.5 has significantly enhanced the capability of the. NET platform and even changed the way we use some things.
Advantages of Expression Tree
Because.. Net 3.5 language (such as C #3.0, VB. net 9.0) are all syntactically integrated into the construction of the Expression Tree. Therefore, API designers can take full advantage of the Expression Tree to provide more powerful and easy-to-use APIs. There are three main advantages:
Strong type
Clear Semantics
Simplified API Development
Strong type
Taking the famous mock framework nmock in the. NET platform as an example, the following code constructs a mock object for the icalculator interface and specifies a set of input and output for the sum method:
VaR mocks = new mockery ();
VaR mockcalculator = mock. newmock <icalculator> ();
Wrong CT. Once. On (mockcalculator)
. Method ("sum ")
. With (1, 2)
. Will (return. value (3); in stark contrast,. moq, a rising star of the mock framework in the net platform, makes full use of the lambda expression feature in C #3.0 to improve the API. Therefore, the approximate implementation of the above Code in Moq is:
Mock <icalculator> mock = new mock <icalculator> ();
Mock. Setup (C => C. sum (1, 2). Returns (3 );
Nmock uses a string to represent a "method" and an object array to represent a parameter. The method of storing returned values with an object completely becomes a "method call" type in Moq ". In this way, developers can get better tool support by using Moq, such as intelliisense and static compiler checks.
Clear Semantics
In terms of syntax, constructing an expression tree using lambda expressions is similar to the most common statements in advanced languages. Because the Expression Tree is only "built" when used, rather than "executed", API designers can regard it as a natural DSL. For example, in Moq, We can flexibly specify the behavior of the icalculator object:
Mock <icalculator> mock = new mock <icalculator> ();
Mock. Setup (C => C. Divide (it. isany <int> (), 0). Throws (New dividebyzeroexception ());
Mock. Setup (C => C. Divide (0, it. is <int> (I => I! = 0). Returns (0 );
Simplified API Development
Strictly speaking, "Clear Semantics" is related to API design, and is not the patent of Expression Tree. For example, the same as the mock framework on the. NET platform, rhinomocks uses the following syntax to define the mock object behavior:
VaR mocks = new mockrepository ();
VaR mockcalculator = mocks. createmock <icalculator> ();
Except CT. Call (mockcalculator. sum (1, 2). Return (3); this syntax is not lost in the semantics embodied in lambda expressions. However, using lambda expressions greatly affects the difficulty of implementing such APIs. In rhinomocks, the sum method is actually called during statement execution, so we must use advanced. NET technologies such as dynamic types to implement such syntax. In the Moq framework, c => C. code like sum (1, 2) is constructed as an Expression Tree and becomes "data" without calling the sum method. What API designers need to do is to analyze the data to obtain the meaning that API users want.
Expression Tree Calculation
Computing the expression tree is the most common task in processing the Expression Tree. Almost in this case, any work that processes the expression tree cannot avoid this problem. Here, the calculation of the Expression Tree refers to converting a complex expression tree into a constant. For example, you can convert the expression tree on the left to the constant on the right.
Note that the result on the right side is a constant instead of a constantexpression object. Of course, when necessary, we can also reconstruct a constantexpression object to form a new expression tree for subsequent analysis. This example is very simple, and the expressions encountered during actual use are often more complex, they may include "Object Construction", "subscript access", "method call", "attribute read", and "? . What they have in common is that they inherit from the expression base class and can be eventually calculated as a constant.
The traditional Expression Tree calculation method is to compile it into a strongly typed delegate object and execute it as follows:
Expression <func <datetime> expr = () => datetime. Now. adddays (1 );
Func <datetime> Tomorrow = expr. Compile ();
Console. writeline (tomorrow ());
To calculate an ambiguous expression tree, we need to write a general eval method, as shown below:
Static object eval (expression expr)
{
Lambdaexpression Lambda = expression. Lambda (expr );
Delegate func = lambda. Compile ();
Return func. dynamicinvoke (null );
}
Static void main (string [] ARGs)
{
Expression <func <datetime> expr = () => datetime. Now. adddays (1 );
Console. writeline (eval (expr. Body ));
}
To put it simply, the general method for calculating the Expression Tree is divided into three steps:
Encapsulate the expression tree in a lambdaexpression object
Call the compile method of lambdaexpression to dynamically generate a delegate object
Use the dynamicinvoke method to call the delegate object and obtain its return value.
The compile method uses emit internally, while the dynamicinvoke method is similar to the reflection call in nature. Therefore, this general expression calculation method brings a relatively considerable overhead. In some scenarios, a large number of Expression Tree computing operations are very likely to occur. For example, when developing a view for an ASP. net mvc application, one of the "best practices" is to construct a link using an auxiliary method that supports the Expression Tree. For example:
<H2> Article list </H2> <% foreach (VAR article in model. articles) {%> <div> <% = html. actionlink <articlecontroller> (C => C. detail (article. articleID, 1), article. title) %>
<% For (VAR page = 2; page <= article. maxpage; Page ++) {%> <small> <% = html. actionlink <articlecontroller> (C => C. detail (article. articleID, page), page. tostring () %> </small> <%} %>
</Div> <% }%> the role of the above Code is inArticleThe list page generates a series of links pointing to the detailed page of the article. In the above Code, how many times will the expression tree be computed?
Html. actionlink <articlecontroller> (C => C. Detail (article. ArticleID, 1), article. Title)
HTML. actionlink <articlecontroller> (C => C. detail (article. articleID, page), article. title) it can be seen that each article will be calculated (2 * maxpage-1) times. For a list page with dozens of articles, the number of computations may exceed times. In addition, a variety of other elements on the page, such as the classification list and Tag Cloud, will cause hundreds of Expression Tree computations every time a slightly complex page is generated. From the performance test of Simone chiaretta, it takes about 30 times to generate a link using the expression tree. Based on my local test results, it takes more than one second for a single thread to compute 2.0 simple four arithmetic expressions on a 10 thousand GHz server. This is not a negligible performance overhead. It is imperative to introduce an Expression Tree computing method with better performance.
Reduce compile overhead
If you carefully compare the overhead of the compile and dynamicinvoke methods, you will find that the former occupies 90-95% of the total time consumption. This means that the performance bottleneck of traditional computing methods lies in the compilation process, which is our primary goal for optimization.
Reducing the number of compilations means reusing the compilation results is caching. If you use the key/value pair caching method, the "value" is naturally the result of compilation, that is, the delegate object. What about the "key? It is easy to know that the "key" must be an Expression Tree. However, there is a question that we must think about. What kind of expression tree is suitable as a "key "? For example, can expressions like "(5 + 2) * 3" be directly used as "keys?
Obviously, when we encounter an expression like "(5 + 2) * 3", we can directly obtain the delegate object obtained from the previous compilation. If the two expression trees are "all equal", it is natural to say that "All Equal" is defined as "the structure of the two expression trees is identical, and the values of each constant are equal ". However, this is of little value in actual use because it has at least the following problems:
Low reusability. For example, in the previous example, the values of the article object or page parameter used in each loop are different, and the expression tree needs to be re-compiled each time.
Constants correspond to equal values, which are not necessary to reuse compilation results. For example, if the ArticleID attribute of the article object is equal, the constant in our expression is a complete article object.
To determine whether two objects are equal, each constant involved in calculation must implement the gethashcode and equals Methods correctly. This is a costly side effect.
Since the cache is required, the cache hit rate must be taken into account. The biggest problem of "equality" is that the cache hit rate is too low, which may even lead to a situation where "it is better not to cache. However, after carefully analyzing various situations, we will find that we can reuse the compilation results in a better way.
In a project, as long as the expression tree is not dynamically constructed, the "structure" of the Expression Tree that may appear in the project is definitely limited. Taking the previous example as an example, although we have many loops, the expressions to be calculated have only two different structures: article. articleID and page -- different computations use different "values" to fill the positions of constants. Similarly, the structure of the expression "(5 + 2) * 3" is exactly the same as that of "(4 + 6) * 7. Therefore, when calculating an Expression Tree, We can structure it first, for example:
If we replace all constants in the expression tree with parameterexpression objects of the same type, all expression trees in the system can be changed to several finite structures. The difference between them is that the "constant sequence" extracted during the replacement process is different. If we compile the Expression Tree Containing parameters as the delegate object and cache it, won't we be able to reuse it multiple times? Therefore, the solution to reduce the number of compilations when calculating the expression tree can be divided into three steps:
Extract all constants in the Expression Tree
Extract from cache or reconstruct a delegate object
Use constants as the delegate object for parameter execution
The first two steps are described below. The traditional method to operate the Expression Tree is to use expressionvisitor. First, we implement a constantextrator for step 1, as shown below:
Public class constantextractor: expressionvisitor {
Private list <Object> m_constants;
Public list <Object> extract (expression exp)
{
This. m_constants = new list <Object> ();
This. Visit (exp );
Return this. m_constants;
}
Protected override expression visitconstant (constantexpression C)
{
This. m_constants.add (C. Value );
Return C;
}
}
Since our goal is only a constant, we only need to override the visitconstant method and collect its value. Next, we will compile an expression into a delegate object. Therefore, we implement a weaktypedelegategenerator, which is naturally a subclass of expressionvisitor:
Public class weaktypedelegategenerator: expressionvisitor {
Private list <parameterexpression> m_parameters;
Public Delegate generate (expression exp)
{
This. m_parameters = new list <parameterexpression> ();
VaR body = This. Visit (exp );
VaR Lambda = expression. Lambda (body, this. m_parameters.toarray ());
Return lambda. Compile ();
}
Protected override expression visitconstant (constantexpression C)
{
VaR P = expression. parameter (C. type, "P" + this. m_parameters.count );
This. m_parameters.add (P );
Return P;
}
} Weaktypedelegategenerator converts all constantexpression into parameterexpressions of the same type and collects them. After accessing the entire Expression Tree, the expressions containing parameterexpression are packaged using lambdaexpression, then compile is called for compilation, and the result is returned.
Public class cacheevaluator: ievaluator {
Private Static iexpressioncache <delegate> s_cache = new hashedlistcache <delegate> ();
Private weaktypedelegategenerator m_delegategenerator = new weaktypedelegategenerator ();
Private constantextractor m_constantextrator = new constantextractor ();
Private iexpressioncache <delegate> m_cache;
Private func <expression, delegate> m_creatordelegate;
Public cacheevaluator ()
: This (s_cache)
{}
Public cacheevaluator (iexpressioncache <delegate> cache)
{
This. m_cache = cache;
This. m_creatordelegate = (key) => This. m_delegategenerator.generate (key );
}
Public object eval (expression exp)
{< br> If (exp. nodetype = expressiontype. constant)
{< br> return (constantexpression) exp ). value;
}
VaR parameters = This. m_constantextrator.extract (exp );
VaR func = This. m_cache.get (exp, this. m_creatordelegate );
Return func. dynamicinvoke (parameters. toarray ());
}
}
The eval method is defined in the ievaluator interface to "Calculate" an expression object as a constant. In implementing the eval method, cacheevaluator uses constantextrator and weaktypedelegategenerator to extract constants and construct delegate objects respectively. After obtaining the delegate object, we will use the dynamicinvoke method to call constants as parameters. It is worth noting that one of the necessary conditions for doing so is that the incoming constants must be consistent with the parameter order of the delegate. Since both contstantextrator and weaktypedelegategenerator are implemented based on the same expressionvisitor, they have the same traversal order for nodes in the same expression tree, so we can be completely assured of this.
The most important component is the cache container. Using the expression tree as the "key" of the cache container is not as easy as a common object. For this reason, I have serialized seven articles on this blog to discuss this issue. These articles provide a variety of solutions and compare and analyze them. Finally, we chose hashedlistcache, which has excellent performance in both time and space. If you have a better (or better performance in your scenario) implementation, you can also replace the default cache container here.
Next we will conduct a simple test. the test data shows that there are 10 arithmetic expressions in each of the four arithmetic expressions with 1-3 operators, and each expression calculates 1000 results respectively.
From this point of view, the traditional method generally takes more than 1.2 seconds to compute expressions of each length, and the cache-enabled computing method controls the time to about 100 milliseconds. This is undoubtedly a significant performance improvement.
Reduce reflection overhead
In traditional calling methods, compilation operations account for 95% of the overhead. Now, after optimization of compilation operations, the total overhead is changed to 10%, which means that at present, compilation and execution take about 50% of the time. If we can optimize the reflection call process, the performance can be further improved. In addition, the current optimization method has another important problem, so we have to modify it. Do you know why in the above example, only four arithmetic expressions with a maximum of three operators are tested? This is because the current practice does not support more operators-Actually the number of parameters.
In a four-character arithmetic expression, the number of constants is always one more than the operator. That is to say, four arithmetic expressions of the three operators, four constants. In the current solution, all constants are replaced with parameters. This is the problem: the lambdaexpression. Compile (parameterexpression []) method only supports up to four parameters. The compile method also has an overload that allows us to specify a new delegate type. It requires matching the number of parameters of the source expression, the parameter type, and the return value type. If no specific delegate type is specified, the Framework selects one of the following delegate objects as the compilation target:
namespace System
{< br> Public Delegate tresult func ();
Public Delegate tresult func (t );
Public Delegate tresult func (T1 A1, T2 A2);
Public Delegate tresult func (T1 A1, T2 A2, T3 A3);
Public Delegate tresult func (T1 A1, T2 A2, t3 A3, T4 A4);
} when the number of parameters exceeds 4, the compile method throws an exception (in. in net 4.0, It is increased to 16 ). To completely solve this problem, it seems that the only method is to dynamically generate the delegate types with various parameter lengths as needed. However, this greatly increases the complexity of the solution and does not help in performance optimization. Is there any way to "uniformly" handle arbitrary signature expressions? The answer is yes, because the "reflection" feature in the. NET Framework gives us a good reference:
Public class methodinfo {
Public object invoke (object instance, object [] parameters );
}
System. the invoke method in the methodinfo class supports arbitrary method signatures because it converts a signature into three parts: "instance", "parameter list", and "return value, each part uses the object type, so it can store any type of objects. As a result, we may try to generalize different expression trees into the same form-to standardize them ". For example, the expression "(5 + 2) * 3" can be converted:
A list <Object> object, which contains 5, 2, and 3 elements.
A new expression: (object) (INT) P [0] + (INT) P [1]) * (INT) P [2]. P is a parameter object of the List <Object> type.
This "Standardization" operation has two main advantages:
As long as it is an expression tree with the same structure, the New Expression Tree obtained after "Standardization" is identical, which greatly improves the cache hit rate.
Regardless of the Expression Tree, the standardized result will always have only one list <Object> parameter, thus avoiding compilation failure caused by too many constants.
We get the Standardized Expression Tree, and we can compile it into the same delegate object. This feature is implemented by the delegategenerator class:
Public class delegategenerator: expressionvisitor {
Private Static readonly methodinfo s_indexerinfo = typeof (list <Object>). getmethod ("get_item ");
Private int m_parametercount;
Private parameterexpression m_parametersexpression;
Public func <list <Object>, Object> Generate (expression exp)
{
This. m_parametercount = 0;
This. m_parametersexpression =
Expression. parameter (typeof (list <Object>), "Parameters ");
VaR body = This. Visit (exp); // normalize if (body. type! = Typeof (object ))
{
Body = expression. Convert (body, typeof (object ));
}
var Lambda = expression. lambda , Object> (body, this. m_parametersexpression);
return lambda. compile ();
}
protected override expression visitconstant (constantexpression c)
{< br> Expression exp = expression. call (
This. m_parametersexpression,
s_indexerinfo,
expression. constant (this. m_parametercount ++);
return C. type = typeof (object )? Exp: expression. Convert (exp, C. Type);
}< BR >}same as weaktypedelegategenerator, delegategenerator uses constantexpression. However, the latter does not directly replace it with the newly created parameterexpression, but converts it to element subscript access (get_item) for list type parameters. If necessary, type conversion is performed again. The visit process is a standardization process. The final expression tree is compiled into a delegate object that accepts the list as a parameter and returns the object type. As for extracting the list of parameters that extract the constants in the expression tree as the list type, it has been implemented by the previous constantextractor and can be directly used.
By combining delegategenerator, constantextractor, and expressioncache, a new component fastevaluator used to calculate the expression tree can be obtained:
Public class fastevaluator: ievaluator {
Private Static iexpressioncache <func <list <Object>, Object> s_cache =
New hashedlistcache <func <list <Object>, Object> ();
Private delegategenerator m_delegategenerator = new delegategenerator ();
Private constantextractor m_constantextrator = new constantextractor ();
Private iexpressioncache <func <list <Object>, Object> m_cache;
Private func <expression, func <list <Object>, Object> m_creatordelegate;
Public fastevaluator ()
: This (s_cache)
{}
Public fastevaluator (iexpressioncache <func <list <Object>, Object> cache)
{
This. m_cache = cache;
This. m_creatordelegate = (key) => This. m_delegategenerator.generate (key );
}
Public object eval (expression exp)
{
If (exp. nodetype = expressiontype. Constant)
{
Return (constantexpression) exp). value;
}
VaR parameters = This. m_constantextrator.extract (exp );
VaR func = This. m_cache.get (exp, this. m_creatordelegate );
Return func (parameters );
}
} In another simple experiment, we calculated 10 arithmetic expressions for each of the four arithmetic expressions with the number of operators 1-20 for 1000 times. The time consumption of the three implementations is compared as follows:
The main overhead of fastevaluator is to extract data from the expressioncache, which increases linearly with the length of the expression. The number of constant nodes in the four arithmetic expression trees with N operators is n + 1. Therefore, the sum of nodes is 2n + 1. In my personal experience, the number of nodes in the Expression Tree calculated in the project is generally less than 10 ., In this data range, fastevaluator consumes only 1/20 of the computing time of the traditional method. As the number of nodes decreases, the gap between the two methods increases. In addition, because the overhead of reflection calls is saved, fastevaluator significantly improves performance compared to the former, even in the range where cacheevaluator can work normally (1-3 operators.
Summary
The Expression Tree has many advantages such as clear semantics and strong types. It is foreseeable that more and more projects will adopt this method to improve their APIs. In this case, the computing of the expression tree will have a greater impact on program performance. This article proposes an optimization method for Expression Tree computing operations. Different expression trees are standardized into several finite structures and their compilation results are reused. As compilation and reflection operations are reduced, the overhead of expression calculation is greatly reduced.
all the code in this article is published in the fastlambda project in the msdn code gallary. You can modify and use it as needed. In addition, the fastlambda project also contains components that can simplify multiple constant parts of the Expression Tree (for example, simplifying 5 + 2 + 3*4 * X to 7 + 12 * X ), this is very useful for processing the Expression Tree that originally contains parameterexpression (for example, when writing a LINQ provider ). If you are interested in this, you can pay attention to the partialevaluator and fastpartialevaluator classes in the project. The difference between them is that the former uses evaluator and the latter uses fastevaluator to perform partial calculation of the expression tree.