식 트리 실행
식 트리는 일부 코드를 나타내는 데이터 구조입니다. 컴파일되고 실행 가능한 코드가 아닙니다. 식 트리로 표시되는 .NET 코드를 실행하려면 실행 가능한 IL 명령으로 변환해야 합니다. 식 트리를 실행할 때 값이 반환될 수 있거나, 메서드 호출 등의 작업만 수행할 수도 있습니다.
람다 식을 나타내는 식 트리만 실행할 수 있습니다. 람다 식을 나타내는 식 트리는 LambdaExpression 또는 Expression<TDelegate> 형식입니다. 이러한 식 트리를 실행하려면 Compile 메서드를 호출하여 실행 가능한 대리자를 만든 후 대리자를 호출합니다.
참고 항목
대리자의 형식을 알 수 없는 경우, 즉 람다 식이 Expression<TDelegate> 형식이 아니라 LambdaExpression 형식인 경우 대리자를 직접 호출하는 대신 대리자의 DynamicInvoke 메서드를 호출합니다.
식 트리가 람다 식을 나타내지 않는 경우 Lambda<TDelegate>(Expression, IEnumerable<ParameterExpression>) 메서드를 호출하여 원래 식 트리가 본문으로 포함된 새 람다 식을 만들 수 있습니다. 그런 다음 이 섹션의 앞부분에서 설명한 대로 람다 식을 실행할 수 있습니다.
람다 식을 함수로 변환
모든 LambdaExpression 또는 LambdaExpression에서 파생된 모든 형식을 실행 가능한 IL로 변환할 수 있습니다. 다른 식 형식은 코드로 직접 변환할 수 없습니다. 실제로 이 제한은 거의 효과가 없습니다. 람다 식은 실행 가능한 IL(중간 언어)로 변환하여 실행하려는 식의 유일한 형식입니다. System.Linq.Expressions.ConstantExpression을 직접 실행하는 것의 의미를 생각해 보세요. 유용한 의미가 있나요? System.Linq.Expressions.LambdaExpression이거나 LambdaExpression
에서 파생된 형식인 모든 식 트리는 IL로 변환할 수 있습니다. 식 형식 System.Linq.Expressions.Expression<TDelegate> 는 .NET Core 라이브러리에서 유일하게 구체적인 예제입니다. 이 형식은 모든 대리자 형식에 매핑되는 식을 나타내는 데 사용됩니다. 이 형식은 대리자 형식에 매핑되므로 .NET에서 식을 검사하고 람다 식의 시그니처와 일치하는 적절한 대리자에 대해 IL을 생성할 수 있습니다. 대리자 형식은 식 형식을 기반으로 합니다. 강력한 형식의 방식으로 대리자 개체를 사용하려면 반환 형식 및 인수 목록을 알고 있어야 합니다. LambdaExpression.Compile()
메서드는 Delegate
형식을 반환합니다. 컴파일 시간 도구에서 인수 목록 또는 반환 형식을 확인할 수 있도록 하려면 올바른 대리자 형식으로 캐스팅해야 합니다.
대부분의 경우 식과 해당 대리자 간의 간단한 매핑이 존재합니다. 예를 들어, Expression<Func<int>>
로 표시되는 식 트리는 Func<int>
형식의 대리자로 변환됩니다. 반환 형식 및 인수 목록을 사용하는 람다 식의 경우 람다 식으로 표시된 실행 코드의 대상 형식인 대리자 형식이 있습니다.
System.Linq.Expressions.LambdaExpression 형식에는 식 트리를 실행 코드로 변환하는 데 사용되는 LambdaExpression.Compile 및 LambdaExpression.CompileToMethod 멤버가 포함됩니다. Compile
메서드는 대리자를 만듭니다. CompileToMethod
메서드는 식 트리의 컴파일된 출력을 나타내는 IL로 System.Reflection.Emit.MethodBuilder 개체를 업데이트합니다.
Important
CompileToMethod
는 .NET Core 또는 .NET 5 이상에서는 사용할 수 없고 .NET Framework에서만 사용할 수 있습니다.
선택적으로 생성된 대리자 개체에 대한 기호 디버깅 정보를 수신하는 System.Runtime.CompilerServices.DebugInfoGenerator를 제공할 수도 있습니다. DebugInfoGenerator
는 생성된 대리자에 대한 전체 디버깅 정보를 제공합니다.
다음 코드를 사용하여 식을 대리자로 변환합니다.
Expression<Func<int>> add = () => 1 + 2;
var func = add.Compile(); // Create Delegate
var answer = func(); // Invoke Delegate
Console.WriteLine(answer);
다음 코드 예에서는 식 트리를 컴파일하고 실행할 때 사용되는 구체적인 형식을 보여 줍니다.
Expression<Func<int, bool>> expr = num => num < 5;
// Compiling the expression tree into a delegate.
Func<int, bool> result = expr.Compile();
// Invoking the delegate and writing the result to the console.
Console.WriteLine(result(4));
// Prints True.
// You can also use simplified syntax
// to compile and run an expression tree.
// The following line can replace two previous statements.
Console.WriteLine(expr.Compile()(4));
// Also prints True.
다음 코드 예제에서는 람다 식을 만들고 실행하여 숫자의 거듭제곱을 나타내는 식 트리를 실행하는 방법을 보여 줍니다. 숫자의 거듭제곱을 나타내는 결과가 표시됩니다.
// The expression tree to execute.
BinaryExpression be = Expression.Power(Expression.Constant(2d), Expression.Constant(3d));
// Create a lambda expression.
Expression<Func<double>> le = Expression.Lambda<Func<double>>(be);
// Compile the lambda expression.
Func<double> compiledExpression = le.Compile();
// Execute the lambda expression.
double result = compiledExpression();
// Display the result.
Console.WriteLine(result);
// This code produces the following output:
// 8
실행 및 수명
LambdaExpression.Compile()
을 호출할 때 만든 대리자를 호출하여 코드를 실행합니다. 앞의 코드 add.Compile()
은 대리자를 반환합니다. 코드를 실행하는 func()
를 호출하여 해당 대리자를 호출합니다.
이 대리자는 식 트리의 코드를 나타냅니다. 해당 대리자에 대한 핸들을 유지하고 나중에 호출할 수 있습니다. 식 트리가 나타내는 코드를 실행할 때마다 식 트리를 컴파일할 필요는 없습니다. (식 트리는 변경할 수 없으며 나중에 동일한 식 트리를 컴파일하면 동일한 코드를 실행하는 대리자가 만들어집니다.)
주의
불필요한 컴파일 호출을 방지하여 성능을 향상시키기 위해 더 정교한 캐싱 메커니즘을 만들지 마세요. 두 개의 임의 식 트리를 비교하여 동일한 알고리즘을 나타내는지 확인하는 작업에는 시간이 많이 걸립니다. LambdaExpression.Compile()
에 대한 추가 호출을 방지하기 위해 절약한 컴퓨팅 시간은 두 개의 서로 다른 식 트리가 동일한 실행 코드를 생성하는지 확인하는 코드를 실행하는 데 소요되는 시간보다 길 가능성이 높습니다.
제한 사항
람다 식을 대리자로 컴파일하고 해당 대리자를 호출하는 것은 식 트리로 수행할 수 있는 가장 간단한 작업 중 하나입니다. 그러나 이 간단한 작업에서도 주의해야 할 사항이 있습니다.
람다 식은 식에서 참조되는 모든 지역 변수에 대해 클로저를 만듭니다. 대리자의 일부가 되는 모든 변수는 Compile
을 호출하는 위치 및 결과 대리자를 실행할 때 사용할 수 있도록 보장해야 합니다. 컴파일러는 변수가 범위 내에 있는지 확인합니다. 그러나 식이 IDisposable
을 구현하는 변수에 액세스하는 경우 코드는 식 트리에서 보유한 개체를 삭제할 수 있습니다.
예를 들어, 다음 코드는 int
이 IDisposable
을 구현하지 않기 때문에 제대로 작동합니다.
private static Func<int, int> CreateBoundFunc()
{
var constant = 5; // constant is captured by the expression tree
Expression<Func<int, int>> expression = (b) => constant + b;
var rVal = expression.Compile();
return rVal;
}
대리자가 지역 변수 constant
에 대한 참조를 캡처했습니다. 해당 변수는 나중에 CreateBoundFunc
에서 반환한 함수가 실행될 때 언제든지 액세스할 수 있습니다.
그러나 System.IDisposable을 구현하는 다음(다소 인위적인) 클래스를 고려해 보세요.
public class Resource : IDisposable
{
private bool _isDisposed = false;
public int Argument
{
get
{
if (!_isDisposed)
return 5;
else throw new ObjectDisposedException("Resource");
}
}
public void Dispose()
{
_isDisposed = true;
}
}
다음 코드에 표시된 대로 식에서 이를 사용하면 Resource.Argument
속성에서 참조하는 코드를 실행할 때 System.ObjectDisposedException을 가져옵니다.
private static Func<int, int> CreateBoundResource()
{
using (var constant = new Resource()) // constant is captured by the expression tree
{
Expression<Func<int, int>> expression = (b) => constant.Argument + b;
var rVal = expression.Compile();
return rVal;
}
}
이 메서드에서 반환된 대리자는 constant
를 통해 닫히고 삭제되었습니다. 이 대리자는 using
문에서 선언되었기 때문에 삭제되었습니다.
이제 이 메서드에서 반환된 대리자를 실행하면 실행 시점에 ObjectDisposedException
이 throw됩니다.
컴파일 시간 구문을 나타내는 런타임 오류가 발생하면 이상하게 보일 수 있지만 식 트리를 사용하면 이런 환경이 시작됩니다.
이 문제에는 다양한 변형이 있으므로 이를 방지하기 위한 일반적인 지침을 제공하기는 어렵습니다. 식을 정의할 때 지역 변수에 액세스할 때 주의하고, 공용 API를 통해 반환된 식 트리를 만들 때 현재 개체(this
로 표시됨)의 상태에 액세스할 때 주의해야 합니다.
식의 코드는 다른 어셈블리의 메서드나 속성을 참조할 수 있습니다. 해당 어셈블리는 식이 정의될 때, 컴파일될 때 및 결과 대리자가 호출될 때 액세스할 수 있어야 합니다. 존재하지 않는 경우에는 ReferencedAssemblyNotFoundException
이 표시됩니다.
요약
람다 식을 나타내는 식 트리를 컴파일하면 실행할 수 있는 대리자를 만들 수 있습니다. 식 트리는 식 트리로 표시되는 코드를 실행하는 하나의 메커니즘을 제공합니다.
식 트리는 생성되는 특정 구문에 대해 실행되는 코드를 나타냅니다. 코드를 컴파일하고 실행하는 환경이 식을 만드는 환경과 일치하는 경우 모든 작업이 예상대로 작동합니다. 그렇지 않은 경우 오류는 예측 가능하며 식 트리를 사용하는 코드의 첫 번째 테스트에서 발견됩니다.
.NET