본문 바로가기
프로그래밍/Effective C#

Effective C# Item 36 : 쿼리 표현식과 메서드 호출 구문이 어떻게 대응되는지 이해하라

by bantomak 2024. 2. 13.

쿼리 표현식과 메서드 호출 구문이 어떻게 대응되는지 이해하라

LINQ는 쿼리 언어와 그 쿼리 언어를 일련의 메서드 집합으로 변환하는 2개의 핵심 구조를 기반으로 한다. C# 컴파일러는 쿼리 언어로 작성된 쿼리 표현식을 메서드 호출 구문으로 변환해 준다.

 

클래스 사용자의 관점에서 볼 때 쿼리 표현식은 단순히 메서드 호출 구문의 다른 표현 방법일 뿐이다. where 절은 적절한 인자를 이용하여 Where()라는 메서드를 호출하는 코드로 변환된다.

 

클래스 설계자의 관점에서는 기본 프레임워크에서 제공하는 메서드들이 어떻게 구현됐는지를 살펴보고 더 나은 방법으로 구현할 수 있을지를 판단해야 한다. 더 나은 구현 방법이 없다면 기본 라이브러리를 그대로 사용하면 되겠지만 개선의 가능성이 있다면 우선 쿼리 표현식이 메서드 호출 구문으로 어떻게 변환되는지를 완벽하게 이해해야 하며, 이러한 이해를 기반으로 메서드의 원형을 올바르게 정의해서 메서드 호출 방식으로 변환이 올바르게 수행될 수 있도록 코드를 작성해야 한다. 

 

delegate R Func<T1, R>(T1 arg1);
delegate R Func<T1, T2, R>(T1 arg1, T2, arg2);

class C
{
    public C<T> Cast<T>();
}

class C<T> : C
{
    public C<T> Where(Func<T, bool> predicate);
    public C<U> Select<U>(Func<T, U> selector);
    public C<V> SelectMany<U, V>(Func<T, C<U>> selector, Func<T, U, V> resultSelector);
    public C<V> Join<U, K, V>(C<U> inner, Func<T, K> outerKeySlector, Func<U, K> innerKeySlector, Func<T, U, V> resultSlector);
    public C<V> GroupJoin<U, K, V>(C<U> inner, Func<T, K> outerKeySlector, Func<U, K> innerKeySlector, Func<T, C<U>, V> resultSlector);
    public O<T> OrderBy<K>(Func<T, K> keySelector);
    public O<T> OrderByDescending<K>(Func<T, K> keySelector);
    public C<G<K, T>> GroupBy<K>(Func<T, K> keySelector);
    public C<G<K, E>> GroupBy<K, E>(Func<T, K> keySelector, Func<T, E> elementSelector);
}

class O<T> : C<T>
{
    public O<T> ThenBy<K>(Func<T, K> keySelector);
    public O<T> ThenByDescending<K>(Func<T, K> keySelector);
}

class G<K, T> : C<T>
{
    public K Key { get; }
}

 

.NET BCL은 이 패턴을 범용적으로 구현한 구현체 두 가지를 제공한다. System.Linq.Enumerable 클래스는 IEnumerable<T>에 대한 확장 메서드의 형태로 이 패턴을 구현하고 있다. 더하여 System.Linq.Queryable 클래스는 IQueryable<T>에 대한 확장 메서드의 형태로 유사한 기능을 구현하고 있으며, 쿼리 제공자에게 쿼리 표현식을 다른 포맷으로 변환할 수 있는 기능도 함께 제공한다. .NET BCL 사용자는 대체로 이 두 가지 구현체 중 하나를 사용하게 될 것이다.

 

클래스 설계자라면 IEnumerable<T>나 IQueryable<T>를 구현한 데이터 소스(혹은 IEnumerable<T>나 IQueryable<T>를 이용하여 닫힌 제네릭 타입)를 만들 수 있을 것이다. 그런데 이 경우 해당 타입은 이미 쿼리 표현식 패턴을 구현한다고 볼 수 있다. 이미 확장 메서드를 통해 필요한 쿼리 표현식 패턴이 모두 구현되어 있기 때문이다.

 

간단한 예제를 통해서 컴파일러가 수행하는 변환 과정을 따라가 보도록 하자.

 

Select() 메서드

다음 쿼리 구문에서 where, select와 n을 주목하기 바란다.

 

var numbers = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
var smallNumbers = from n in numbers 
                   where n < 5 
                   select n;

 

from n in numbers 표현식은 numbers의 개별 요소들을 범위 변수 n에 바인딩한다. where 절은 Where() 메서드에서 사용할 필터를 지정하는데 실제로 where n < 5는 numbers.Where(n => n > 5); 로 변환된다.

 

where는 필터 이상의 역할을 수행하지 않는다. Where()는 입력 시퀀스로부터 조건을 만족하는 요소만을 추려낸다. 입력과 출력 시퀀스는 동일한 타입이어야 하고 입력 시퀀스의 요소를 수정하지 않아야 한다.

 

다음으로 쿼리 표현식 내의 select를 변환하는 과정을 수행한다. select 절은 Select() 메서드로 변환된다. 특별한 경우이기는 하지만 간혹 Select() 메서드 호출 자체가 생략되기도 한다. 앞의 예에서 사용된 select 구문은 입력 시퀀스로부터 출력 시퀀스로 옮겨야 할 범위 변수를 선택하기 위한 축약 select로 볼 수 있다. 또한 앞의 쿼리 구문은 where 절을 포함하고 있으므로 입력 시퀀스와 출력 시퀀스가 다르다. 이처럼 입력 시퀀스와 출력 시퀀스가 다를 경우, select문은 삭제될 가능성이 있다. 최종적으로 변경된 메서드 호출 구문은 다음과 같다.

 

var smallNumbers = numbers.Where(n => n < 5);

 

앞의 예제의 경우 다른 표현식의 출력 시퀀스(앞의 예제에서는 Where() 메서드가 반환한)를 즉각적으로 이어받아 Select()를 호출하므로 Select() 메서드를 호출할 필요가 없다. 하지만 Select() 메서드가 다른 표현식의 출력 시퀀스를 즉각적으로 이어받는 경우가 아니라면 Select() 메서드를 삭제할 수 없다.

 

var allNumbers = from n in numbers select n;

 

이 쿼리는 다음과 같은 메서드 호출 구문으로 변환된다.

 

var allNumbers = numbers.Select(n => n);

 

쿼리 표현식에서 select는 입력값을 다른 값으로 바꾸거나 혹은 타입을 변환하는 용도로 사용된다. 다음 쿼리는 select를 이용하여 입력값을 다른 값으로 바꾸는 예다.

 

var numbers = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
var smallNumbers = from n in numbers 
                   where n < 5 
                   select n * n;

 

다음 예는 입력값을 다른 타입으로 변경하는 경우다.

 

var numbers = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
var smallNumbers = from n in numbers select new { Number = n, Square = n * n};

 

이 예의 select 절은 쿼리 표현식 패턴 내의 Select() 메서드로 변환된다.

 

var sqaures = numbers.Select(n => new { Number = n, Square = n * n });

 

이 예에서 Select() 메서드는 입력값을 다른 타입으로 변환하는 용도로 사용되는데, 입력 시퀀스 내의 개별 요소에 대하여 각기 새로운 타입의 객체를 생성하여 출력 시퀀스로 내보낸다. 이때 Select() 메서드에서 입력 시퀀스 내의 요소들을 수정해서는 안된다.

 

new { ... } 관련해서는 아래의 글을 참고하자.

 

C# 익명 타입 (Anonymous Type)

익명 타입(Anonymous Type)이란? C#에서 어떤 클래스를 사용하기 위해서는 일반적으로 먼저 클래스를 정의한 후 사용한다. 하지만 C# 3.0부터 클래스를 미리 정의하지 않고 사용할 수 있게 하는 익명

jettstream.tistory.com

 

OrderBy(), ThenBy(), OrderByDescending(), ThenByDescending() 메서드

var people = from e in employees
             where e.Age > 30 
             orderby e.LastName, e.FirstName, e.Age 
             select e;

 

이 쿼리는 다음과 같이 변환된다.

 

var people = employees.Where(e => e.Age > 30).OrderBy(e => e.LastName).ThenBy(e => e.FirstName).ThenBy(e => e.Age);

 

OrderBy()의 결과가 ThenBy()에 의해서 처리되고 그 결과가 다시 ThenBy()에 의해 처리되고 있음에 주목하기 바란다. 이러한 절차로 수행되기 때문에 이미 정렬된 결과물 내에서 정렬 키가 동일한 요소들 내에서만 ThenBy()를 이용하여 다시 추가 정렬을 수행한다. 그리고 이를 구현하기 위해 임의의 표식을 포함하곤 한다.

 

만약 쿼리 표현식 내에서 orderby 절을 각기 구분하여 쓰면 이와 같이 변환 작업이 수행되지 않는다. 다음 쿼리는 시퀀스를 LastName으로 정렬하고, FirstName으로 정렬한 후, 마지막으로 Age로 또다시 정렬을 수행하는 예다.

 

// 올바르지 않다. 전체 시퀀스를 세 번에 걸쳐 정렬한다.
var people = from e in employees 
             where e.Age > 30
             orderby e.LastName
             orderby e.FirstName
             orderby e.Age 
             select e;

 

OrderBy() 메서드는 뒤따라 오는 ThenBy() 절이 더 효율적으로 수행되고 전체 쿼리에 대해서 올바른 타입을 생성하기 위해서 입력 시퀀스와는 다른 타입의 출력 시퀀스를 만들어낸다. ThenBy()는 정렬되지 않은 시퀀스에 대해서는 올바르게 작업을 수행하지 못하므로 반드시 정렬된 시퀀스를 입력으로 줘야 한다. 

 

이번에는 여러 단계를 거쳐 쿼리 표현식을 메서드 호출 구문으로 변환하는 경우를 살펴보자. 쿼리 표현식 내에 그룹핑이 포함되어 있거나 혹은 여러 개의 from 절이 포함된다면 여러 단계를 거쳐 메서드 호출 구문으로 변환된다. 다음 예제를 살펴보자.

 

var results = from e in employees
              group e by e.Department into d
              select new
              {
                  Department = d.key,
                  Size = d.Count()
              };

 

다른 변환 작업에 앞서 group into 구문을 중첩 쿼리로 변경한다.

 

var results = from d in
              from e in employees group e by e.Department
              select new
              {
                  Department = d.key,
                  Size = d.Count()
              };

 

var result = employees.GroupBy(e => e.Department).
             Select(d => new { Department = d.key, Size = d.Count() });

 

이 쿼리 표현식은 단일 시퀀스를 반환하는 GroupBy() 메서드의 사용 예를 보여준다. 쿼리 표현식 패턴 내에 정의되어 있는 GroupBy() 메서드는 그룹에 대한 시퀀스를 반환하도록 정의되어 있다. 여기서 그룹이란 하나의 키와 값의 리스트로 구성된 타입을 일컫는다.

 

var results = from e in employees 
              group e by e.Department into d
              select new
              {
                  Department = d.key,
                  Size = d.AsEnumerable()
              };

 

이 쿼리는 다음과 같은 메서드 호출 구문으로 변경된다.

 

var result = employees.GroupBy(e => e.Department).
             Select(d => new { Department = d.key, Size = d.AsEnumerable() });

 

GroupBy() 메서드는 개별 요소로 하나의 키와 값의 리스트를 쌍으로 갖는 시퀀스를 생성한다. 키는 그룹을 선택하는 셀렉터 역할을 하고 값의 리스트는 그룹 내의 항목들을 나타낸다.

 

SelectMany() 메서드

SelectMany()는 2개의 입력 시퀀스를 이용하여 카티전 곱(Cartesian Product)을 생성한다. 다음의 쿼리를 살펴보자.

 

int[] odds = { 1, 3, 5, 7 };
int[] evens = { 2, 4, 6, 8 };

var pairs = from oddNumber in odds
            from evenNumber in evens
            select new
            {
                oddNumber,
                evenNumber,
                Sum = oddNumber + evenNumber
            };

foreach (var pair in pairs)
{
    Console.WriteLine(pair);
}

 

 

여러 개의 from 절을 포함하는 쿼리 표현식은 SelectMany() 메서드로 변환한다. 따라서 앞의 쿼리 표현식도 SelectMany()를 사용하는 메서드 호출 구문으로 변환한다.

 

int[] odds = { 1, 3, 5, 7 };
int[] evens = { 2, 4, 6, 8 };

var value = odds.SelectMany(oddNumber => evens,
    (oddNumber, evenNumber) =>
        new {
            oddNumber,
            evenNumber,
            Sum = oddNumber + evenNumber,
        });

foreach (var pair in value)
{
    Console.WriteLine(pair);
}

 

 

SelectMany() 메서드의 첫 번째 매개변수는 첫 번째 입력 시퀀스의 각 요소에 두 번째 입력 시퀀스를 매핑하는 함수다. 두 번째 매개변수(출력 선택기, output selector라고도 한다)는 두 입력 시퀀스를 이용하여 출력 시퀀스를 생성하는 함수가 온다.

 

SelectMany()는 첫 번째 시퀀스를 순회하면서 첫 번째 시퀀스 내의 개별 요소에 대해 두 번째 시퀀스를 다시 순회한다. 이러한 과정을 거치면서 쌍으로 구성된 입력값을 만들어낸다. 출력 선택기에는 두 시퀀스를 조합하여 만든 단일 시퀀스가 매개변수로 전달된다.  SelectMany는 다음과 같이 구현할 수도 있다.

 

static IEnumerable<TOutput> SelectMany<T1, T2, TOutput>(
    this IEnumerable<T1> src,
    Func<T1, IEnumerable<T2>> inputSelector,
    Func<T1, T2, TOutput> resultSelector)
{
    foreach (T1 first in src)
    {
        foreach (T2 second in inputSelector(first))
        {
            yield return resultSelector(first, second);
        }
    }
}

 

첫 번째 입력 시퀀스를 순회하는 동안 입력 시퀀스의 현재 값을 활용하여 두 번째 입력 시퀀스를 순회한다. 두 번째 입력 시퀀스의 inputSelector 메서드가 첫 번째 시퀀스의 값에 의존한다는 사실을 안다는 것이 중요하다. 다음으로 두 시퀀스로부터 개별 요소의 쌍을 매개변수로  resultSelector를 호출한다.

 

SelectMany() 메서드는 조합하기 쉬운 메서드라서 3개의 from 절을 사용하면 2개의 SelectMany() 메서드를 호출하도록 코드가 생성된다. 첫 번째 SelectMany() 메서드의 결과 쌍은 두 번째 SelectMany()의 입력 시퀀스로 전달되며, 그 결과 트리플이 생성된다. 이 트리플에는 3개의 입력 시퀀스의 모든 조합을 포함하게 된다.

 

var triples = from n in new int[] { 1, 2, 3}
              from s in new string[] { "one", "two", "three" }
              from r in enw string[] { "I", "II", "III" }
              select new { Arabic = n, Word = s, Roman = r };

 

앞의 코드는 다음과 같이 변환된다.

 

var numbers = new int[] { 1, 2, 3};
var words = new string[] { "one", "two", "three" }
var romans = new string[] { "I", "II", "III" }

var triple = numbers.SelectMany(n => words, 
    (n, s) => new { n, s }).
    SelectMany(pair => romans, (pair, n) =>
    new { Arabic = pair.n, Word = pair.s, Roman = n });

 

이처럼 SelectMany() 메서드를 이용하면 3개 혹은 그 이상의 입력 시퀀스를 조합할 수 있다. 앞의 예제는 SelectMnay() 메서드와 익명 타입을 함께 사용하는 방법도 보여주고 있는데 실제로 SelectMany()의 출력 시퀀스는 익명 타입의 시퀀스다.

 

Join(), GroupJoin() 메서드

이 둘 모두 조인(join) 표현식에서 사용된다. GroupJoin()은 조인 표현식에 into 절이 포함된 경우 사용되며, Join()은 조인 표현식에 into 절이 포함되지 않은 경우 사용된다.

 

var numbers = new int[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
var labels = new string[] { "0", "1", "2", "3", "4", "5" }

var query = from num in numberss
            join lable in lables on num.ToString() equals lable
            select new { num, label };

 

앞의 코드는 다음과 같이 변환된다.

 

var query = numbers.Join(labels, num => num.ToString()),
            label => lable, (num, label) => new { num, label };

 

into 절을 사용하면 세분된 결과 목록을 만든다.

 

var groups = from p in projects
             join t in tasks on p equlas t.Parent
             into projTasks
             select new { Project = p, projTasks };

 

앞의 코드는 GroupJoin을 이용하여 다음과 같이 변환된다.

 

var groups = projects.GroupJoin(tasks,
    p => p, t => t.Parent, (p, projTasks) =>
    new { Project = p, TaskList = projTasks });

 

출처

 

이펙티브 C# - 예스24

더 나은 C# 코드를 작성하는 새로운 방법 50가지 C#은 전통적인 .NET 기반 개발에서 유니티 게임 엔진으로 개발 영역을 확대하면서 더욱 주목받고 있다. 또한 자마린으로 다양한 모바일 플랫폼에

댓글