식 트리 실행

식 트리는 일부 코드를 나타내는 데이터 구조입니다. 컴파일되고 실행 가능한 코드가 아닙니다. 식 트리로 표시되는 .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.CompileLambdaExpression.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을 구현하는 변수에 액세스하는 경우 코드는 식 트리에서 보유한 개체를 삭제할 수 있습니다.

예를 들어, 다음 코드는 intIDisposable을 구현하지 않기 때문에 제대로 작동합니다.

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이 표시됩니다.

요약

람다 식을 나타내는 식 트리를 컴파일하면 실행할 수 있는 대리자를 만들 수 있습니다. 식 트리는 식 트리로 표시되는 코드를 실행하는 하나의 메커니즘을 제공합니다.

식 트리는 생성되는 특정 구문에 대해 실행되는 코드를 나타냅니다. 코드를 컴파일하고 실행하는 환경이 식을 만드는 환경과 일치하는 경우 모든 작업이 예상대로 작동합니다. 그렇지 않은 경우 오류는 예측 가능하며 식 트리를 사용하는 코드의 첫 번째 테스트에서 발견됩니다.