다음을 통해 공유


식 트리 번역

이 문서에서는 식 트리의 수정된 복사본을 빌드하는 동안 식 트리의 각 노드를 방문하는 방법을 알아봅니다. 다른 환경으로 변환할 수 있도록 식 트리를 변환하여 알고리즘을 이해합니다. 생성된 알고리즘을 변경합니다. 로깅을 추가하고, 메서드 호출을 가로채고, 추적하거나, 다른 용도로 사용할 수 있습니다.

식 트리를 번역하기 위해 빌드하는 코드는 트리의 모든 노드를 방문하기 위해 이미 본 코드의 확장입니다. 식 트리를 변환할 때, 모든 노드를 방문하고, 그 과정에서 새 트리를 구축합니다. 새 트리에는 원래 노드 또는 트리에 배치한 새 노드에 대한 참조가 포함될 수 있습니다.

식 트리를 방문하여 일부 대체 노드가 있는 새 트리를 만들어 보겠습니다. 이 예제에서는 상수를 10배 더 큰 상수로 바꿔봅시다. 그렇지 않으면 식 트리를 그대로 둡니다. 상수 값을 읽고 새 상수로 바꾸는 대신 상수 노드를 곱셈을 수행하는 새 노드로 바꿔서 대체합니다.

여기서 상수 노드를 찾으면 원래 상수 및 상수 10인 자식을 가진 새로운 곱셈 노드를 만듭니다.

private static Expression ReplaceNodes(Expression original)
{
    if (original.NodeType == ExpressionType.Constant)
    {
        return Expression.Multiply(original, Expression.Constant(10));
    }
    else if (original.NodeType == ExpressionType.Add)
    {
        var binaryExpression = (BinaryExpression)original;
        return Expression.Add(
            ReplaceNodes(binaryExpression.Left),
            ReplaceNodes(binaryExpression.Right));
    }
    return original;
}

원래 노드를 대체 노드로 바꿔 새 트리를 만듭니다. 대체된 트리를 컴파일하고 실행하여 변경 내용을 확인합니다.

var one = Expression.Constant(1, typeof(int));
var two = Expression.Constant(2, typeof(int));
var addition = Expression.Add(one, two);
var sum = ReplaceNodes(addition);
var executableFunc = Expression.Lambda(sum);

var func = (Func<int>)executableFunc.Compile();
var answer = func();
Console.WriteLine(answer);

새 트리 빌드는 기존 트리의 노드를 방문하고 새 노드를 만들어 트리에 삽입하는 조합입니다. 이전 예제에서는 변경할 수 없는 식 트리의 중요성을 보여 줍니다. 이전 코드에서 만든 새 트리에는 새로 만든 노드와 기존 트리의 노드가 혼합되어 있습니다. 기존 트리의 노드를 수정할 수 없으므로 두 트리에서 노드를 사용할 수 있습니다. 노드를 다시 사용하면 상당한 메모리 효율성이 발생합니다. 트리 전체 또는 여러 식 트리에서 동일한 노드를 사용할 수 있습니다. 노드는 수정할 수 없으므로 필요할 때마다 동일한 노드를 다시 사용할 수 있습니다.

트래버스를 수행하고 추가 작업을 실행하다.

추가 노드의 트리를 걷고 결과를 계산하는 두 번째 방문자를 빌드하여 새 트리를 확인해 보겠습니다. 지금까지 본 방문자에 대한 몇 가지 수정을 해보세요. 이 새 버전에서 방문자는 이 시점까지 추가 작업의 부분 합계를 반환합니다. 상수 식의 경우 상수 식의 값일 뿐입니다. 덧셈 식의 경우, 트리를 순회한 후 왼쪽 및 오른쪽 피연산자의 합이 결과로 생성됩니다.

var one = Expression.Constant(1, typeof(int));
var two = Expression.Constant(2, typeof(int));
var three = Expression.Constant(3, typeof(int));
var four = Expression.Constant(4, typeof(int));
var addition = Expression.Add(one, two);
var add2 = Expression.Add(three, four);
var sum = Expression.Add(addition, add2);

// Declare the delegate, so you can call it
// from itself recursively:
Func<Expression, int> aggregate = null!;
// Aggregate, return constants, or the sum of the left and right operand.
// Major simplification: Assume every binary expression is an addition.
aggregate = (exp) =>
    exp.NodeType == ExpressionType.Constant ?
    (int)((ConstantExpression)exp).Value :
    aggregate(((BinaryExpression)exp).Left) + aggregate(((BinaryExpression)exp).Right);

var theSum = aggregate(sum);
Console.WriteLine(theSum);

여기에는 꽤 많은 코드가 있지만 개념에 쉽게 접근할 수 있습니다. 이 코드는 깊이 있는 첫 번째 검색에서 자식을 방문합니다. 상수 노드가 발견되면 방문자는 상수의 값을 반환합니다. 방문자가 두 자녀를 모두 방문한 후 해당 자식은 해당 하위 트리에 대해 계산된 합계를 계산했습니다. 더하기 노드는 이제 합계를 계산할 수 있습니다. 식 트리의 모든 노드를 방문한 후에는 합계가 계산됩니다. 디버거에서 샘플을 실행하여 프로세스를 추적할 수 있습니다.

노드를 분석하는 방법과 트리를 트래버스하여 합계를 계산하는 방법을 더 쉽게 추적할 수 있도록 하겠습니다. 다음은 꽤 많은 추적 정보를 포함하는 Aggregate 메서드의 업데이트된 버전입니다.

private static int Aggregate(Expression exp)
{
    if (exp.NodeType == ExpressionType.Constant)
    {
        var constantExp = (ConstantExpression)exp;
        Console.Error.WriteLine($"Found Constant: {constantExp.Value}");
        if (constantExp.Value is int value)
        {
            return value;
        }
        else
        {
            return 0;
        }
    }
    else if (exp.NodeType == ExpressionType.Add)
    {
        var addExp = (BinaryExpression)exp;
        Console.Error.WriteLine("Found Addition Expression");
        Console.Error.WriteLine("Computing Left node");
        var leftOperand = Aggregate(addExp.Left);
        Console.Error.WriteLine($"Left is: {leftOperand}");
        Console.Error.WriteLine("Computing Right node");
        var rightOperand = Aggregate(addExp.Right);
        Console.Error.WriteLine($"Right is: {rightOperand}");
        var sum = leftOperand + rightOperand;
        Console.Error.WriteLine($"Computed sum: {sum}");
        return sum;
    }
    else throw new NotSupportedException("Haven't written this yet");
}

이를 식에 적용하면 sum 다음과 같은 출력이 생성됩니다.

10
Found Addition Expression
Computing Left node
Found Addition Expression
Computing Left node
Found Constant: 1
Left is: 1
Computing Right node
Found Constant: 2
Right is: 2
Computed sum: 3
Left is: 3
Computing Right node
Found Addition Expression
Computing Left node
Found Constant: 3
Left is: 3
Computing Right node
Found Constant: 4
Right is: 4
Computed sum: 7
Right is: 7
Computed sum: 10
10

출력 결과를 확인하고 앞의 코드를 따라가세요. 코드가 각 노드를 방문하여 트리를 통과하고 합계를 찾을 때 합계를 계산하는 방법을 확인할 수 있어야 합니다.

이제 sum1로 주어진 식을 사용하여 다른 실행을 살펴보겠습니다.

Expression<Func<int>> sum1 = () => 1 + (2 + (3 + 4));

이 식을 검사하는 출력은 다음과 같습니다.

Found Addition Expression
Computing Left node
Found Constant: 1
Left is: 1
Computing Right node
Found Addition Expression
Computing Left node
Found Constant: 2
Left is: 2
Computing Right node
Found Addition Expression
Computing Left node
Found Constant: 3
Left is: 3
Computing Right node
Found Constant: 4
Right is: 4
Computed sum: 7
Right is: 7
Computed sum: 9
Right is: 9
Computed sum: 10
10

최종 대답은 동일하지만 트리 순회는 다릅니다. 트리가 먼저 발생하는 다른 작업으로 구성되었으므로 노드는 다른 순서로 이동됩니다.

수정된 복사본 만들기

콘솔 애플리케이션 프로젝트를 만듭니다. 네임스페이스 using에 대한 파일에 System.Linq.Expressions 지시문을 추가합니다. AndAlsoModifier 프로젝트에 클래스를 추가합니다.

public class AndAlsoModifier : ExpressionVisitor
{
    public Expression Modify(Expression expression)
    {
        return Visit(expression);
    }

    protected override Expression VisitBinary(BinaryExpression b)
    {
        if (b.NodeType == ExpressionType.AndAlso)
        {
            Expression left = this.Visit(b.Left);
            Expression right = this.Visit(b.Right);

            // Make this binary expression an OrElse operation instead of an AndAlso operation.
            return Expression.MakeBinary(ExpressionType.OrElse, left, right, b.IsLiftedToNull, b.Method);
        }

        return base.VisitBinary(b);
    }
}

이 클래스는 클래스를 ExpressionVisitor 상속하며 조건 AND 부 작업을 나타내는 식을 수정하기 위해 특수화됩니다. 이 작업들은 조건 AND에서 조건 OR로 변경됩니다. 클래스는 조건 VisitBinary 식이 이진 식으로 표현되기 때문에 기본형의 AND 메서드를 재정의합니다. 메서드에서 VisitBinary에 전달된 식이 조건부 AND 연산을 나타내는 경우, 코드에서는 조건 OR 연산자 대신 조건 AND 연산자를 포함하는 새로운 식을 생성합니다. 전달된 VisitBinary 식이 조건 AND 연산을 나타내지 않는다면, 메서드는 기본 클래스의 구현을 따릅니다. 기본 클래스 메서드는 전달되는 식 트리와 같은 노드를 생성하지만 노드에는 해당 하위 트리가 방문자가 재귀적으로 생성하는 식 트리로 대체됩니다.

네임스페이스 using에 대한 파일에 System.Linq.Expressions 지시문을 추가합니다. Program.cs 파일의 Main 메서드에 코드를 추가하여 식 트리를 만들고 이를 수정하는 메서드에 전달합니다.

Expression<Func<string, bool>> expr = name => name.Length > 10 && name.StartsWith("G");
Console.WriteLine(expr);

AndAlsoModifier treeModifier = new AndAlsoModifier();
Expression modifiedExpr = treeModifier.Modify((Expression)expr);

Console.WriteLine(modifiedExpr);

/*  This code produces the following output:

    name => ((name.Length > 10) && name.StartsWith("G"))
    name => ((name.Length > 10) || name.StartsWith("G"))
*/

이 코드는 조건 AND 부 작업을 포함하는 식을 만듭니다. 그런 다음 클래스의 인스턴스를 AndAlsoModifier 만들고 이 클래스의 메서드에 Modify 식을 전달합니다. 원래 및 수정된 식 트리가 모두 출력되어 변경 내용을 표시합니다. 애플리케이션을 컴파일하고 실행합니다.

더 알아보세요

이 샘플에서는 식 트리가 나타내는 알고리즘을 트래버스하고 해석하기 위해 빌드할 코드의 작은 하위 집합을 보여 줍니다. 식 트리를 다른 언어로 번역하는 범용 라이브러리를 빌드하는 방법에 대한 자세한 내용은 Matt Warren 의 이 시리즈를 참조하세요. 식 트리에서 찾을 수 있는 코드를 변환하는 방법에 대해 자세히 설명합니다.

이제 당신은 식 트리의 진정한 힘을 보았습니다. 코드 집합을 검사하고, 해당 코드를 변경하고, 변경된 버전을 실행합니다. 표현식 트리는 변경할 수 없으므로 기존 표현식 트리의 구성 요소를 사용하여 새 표현식 트리를 만듭니다. 노드를 다시 사용하면 수정된 식 트리를 만드는 데 필요한 메모리 양이 최소화됩니다.