아이템 36. 쿼리 표현식과 메서드 호출 구문이 어떻게 대응되는지 이해하라
LINQ는 쿼리 언어와 그 쿼리 언어를 일련의 메서드 집합으로 변환하는 2개의 핵심 구조로 기반한다.
모든 쿼리식은 하나 혹은 여러 개의 메서드로 매핑된다. 클래스 사용자의 관점에서 볼 때 쿼리 표현식은 단순히 메서드 호출 구문의 다른 표현 이다. 예로 where 절은 Where() 메서드를 호출하는 코드로 변환된다.
쿼리 표현식의 예
Where / Select / SelectMany / Join / GroupJoin / OrderBy / OrderByDescending
이러한 메서드는 System.Linq.Enumerable 클래스는 IEnumerable<T>에 대한 확장 메서드로 구현하고 있다. 비슷하게 System.Linq.Queryable 클래스는 IQueryable<T>에 대한 확장 메서드의 유사한 기능을 구현한다.
select, where
var numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9};
var smallNumbers = from n in numbers
where n < 5
select n;
where 필터는 입력 시퀀스로 부터 조건을 만족하는 요소만 추려내는 역할로 Where(n => n <5)와 같이 변환된다. where 구문이 입력 요소를 변경하는 지 검사를 진행하지는 않지만 구현 패턴에 그런 사용을 하지 않는다고 가정한다.
위의 예에서는 Where(n =>n < 5)의 결과가 최종 컬렉션이므로 Select(n = > n)을 추가적으로 호출할 필요는 없다.
var numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9};
var asNewItems = from n in numbers
where n < 5
select new { Index = n, Label = CategorizeAsString(n)};
위와 같이 select 내용이 변경되면 이제는 새로운 타입의 원소를 생성하므로
Select( n = >new { Index = n, Label = CategorizeAsString(n))으로 변환된다.
orderby, orderby descending
정렬과 관련된 구문은 다음과 같이 변환된다.
var people = from p in participants
where p.Wage > 10000
orderby p.Donation, p.ParticipateRatio, p.Wage
selet p;
var people = participants.Where(p => p.Wage > 30).
OrderBy(p => p.Donation).
ThenBy(p => p.ParticipateRatio).
ThenBy(p => p.Wage);
ThenBy는 OrderBy로 정렬된 시퀀스를 추가 정렬한다.
group into
var results = from c in characters
group c by c.Type into t
select new
{
Category = t.Category,
TypeName = t.TypeName,
Num = t.Count()
};
group into 는 먼저 중첩 쿼리문으로 재해석 된다.
var results = from t in
from c in characters group c by c.Type
select new
{
Category = t.Category,
TypeName = t.TypeName,
Num = t.Count()
};
해당 쿼리문은 다음 메서드로 변환된다.
var results = characters.GroupBy(c => c.Type)
.Select(new
{
Category = t.Category,
TypeName = t.TypeName,
Num = t.Count()
});
SelectMany() / Join()
SelectMany()는 두 입력 시퀀스에 대해 Cartesian 곱을 한 결과를 가진다.
int[] x_coords = {-2, -1, 0, 1, 2};
int[] y_coords = {-2, -1, 0, 1, 2};
var pairs = from x in x_coords
from y in y_coords
select new
{
x,
y,
Distance = Math.Sqrt(x*x + y*y);
};
두 시퀀스에서 select 한 결과는 다음과 같이 변환된다.
var values = x_coords.SelectMany(x => ys,
(x, y) => new
{
x,
y,
Distance = Math.Sqrt(x*x+y*y)
}
SelectMany는 첫 인자에서 첫 컬력션의 원소마다 두번째 컬렉션 전체를 맵핑하며 두번째 인자는 각 모든 원소쌍에 대해 생성할 원소를 정의한다.
public static IEnumerable<TOutput> SelectMany<T1, T2, TOutput>(
this IEnumerable<T1> src,
Func<T1, IEnumerable<T2>> srcMapper,
Func<T1, T2, TOutput> Selector)
{
foreach (T1 first in src)
{
foreach (T2 second in srcMapper(first))
yield return Selector(first, second);
}
}
SelectMany는 첫번째 컬렉션, 첫번째 컬렉션의 각 원소를 두번째 컬렉션으로 맵핑하는 함수, 그리고 모든 원소 쌍에 대해 생성할 원소 함수가 전달된다.
Join()
var numbers = new int[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
var labels = new string[] {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"};
var query = from num in numbers
join label in labels on num.ToString() equals label
select new {num, label};
위 코드는 다음과 같이 변환된다.
var query = numbers.Join(labels,
num => num.ToString(),
label => label,
(num, label) => new {num, label});
아이템 37. 쿼리를 사용할 때는 즉시 평가보다 지연 평가가 낫다.
쿼리식도 IEnumerable<T>와 마찬가지로 지연 평가를 제공한다. 쿼리식의 결과는 IQueryable<T> 인데 이는 IEnumerable<T>를 상속(구현)하기 때문에 가능하다.
무한한 시퀀스를 생성하는 쿼리문 혹은 LINQ 확장 메서드문이 있다하자.
var sequence = GenerateInifinite();
foreach(var element in sequence)
Console.WriteLine($"{element}");
위 순회는 무한히 진행하므로 끝나지 않을 것이다.
Take()와 같은 메서드는 전체 컬렉션중 일부만 반환하기 때문에 지연 평가가 모든 원소에 대해 이루어지지 않고 무한한 컬렉션의 순회는 중단된다.
where는 모든 원소에 대해 평가가 이루어지므로 항상 모든 원소를 순회한다. orderby의 정렬 구문 또한 모든 원소를 보아야하기 때문에 모든 원소를 순회한다.
지연 평가를 하지 않고 즉시 순회 결과를 평가하여 그 결과를 List<T>나 배열로 캐싱할 수 있다. ToList()와 ToArray()를 호출하도록 한다.
아이템 38. 메서드보다 람다 표현식이 낫다
쿼리식에서 람다식은 자주 사용된다.
IEnumerable<int> sequence = Utilities.Generate(30, () => { Console.WriteLine("Generate !"); });
var sequenceSecond = sequence.Where((e) => e > 100).Select((e) => new { Value = e, Square = e * e });
람다식을 대리자로 대체하여 사용해도 되지만 그럴 필요가 없다. 왜냐하면 쿼리 표현식 내 람다 표현식은 델리게이트로 변환되어 수행된다. 혹은 LINQ to SQL 절차에서는 해당 람다 표현식은 표현식 트리로 해석 되어 새로운 구문을 생성하고 그 결과를 다른 환경에서 실행할 수 있다.
아이템 39. function과 action 내에서는 예외가 발생하지 않도록 하라
쿼리문 내에서 사용되는 람다식이 예외가 발생했을 경우 그 결과를 되돌리기 어렵고 파악하기가 힘들다.
var allEmployees = FindAllEmployees();
allEmployees.ForEach(e => e.MonthlySalary * 1.05);
각 직원들의 월급을 인상하는 쿼리문이 있다하자. 해당 쿼리의 순회 도중 예외가 발생했다면 일부 직원들은 인상된 월급이 적용되어 있을 것이고 일부는 그대로 일 것이다. 해당 결과에서 어떤 직원들이 적용되었고 그 이전으로 복구시키는 것은 어려운 일이다.
가장 바람직한 방법은 람다 표현식으로 작성된 대리자 형식이 절대로 예외를 발생하지 않게 만드는 것이다. 혹은 복사된 대상에 대하여 모든 작업이 올바르게 끝났을 경우에만 원본을 대체하는 방식으로 처리할 수 있지만 성능 저하를 피할 수 없다.
아이템 40. 지연 수행과 즉시 수행을 구분하라
선언적 코드는 해당 작업의 구성을 설명한다. 명령형 코드는 어떻게 작업이 되어야하는 지가 명확히 드러나며 즉시 수행된다.
명령형 코드는 해당 작업이 수행되기 위해 필요한 요소를 매개변수로 받는다. 각 매개변수의 값은 이전에 처리완료되어 준비가 되어있어야한다.
var result = FinishTask(GetPrepare1(), GetPrepare2(), GetPrepare3());
선언적 코드는 해당 작업이 수행될 때 필요할 것으로 기대되는 구성을 설명하는 것으로 보인다. 작업을 완료하기 위해 GetPrepare1(), GetPrepare2(), GetPrepare3()라는 대리자 형식의 함수들이 사용될 것이라 보인다.
var result = FinishTask(() => GetPrepare1(),() => GetPrepare2(),() => GetPrepare3());
아이템 41. 값비싼 리소스를 캡처하지말라
클로저는 클로저에 바인드된 변수를 포함하는 객체를 생성한다. 클로저 내로 캡쳐된 변수는 참조되는 객체의 수명을 늘린다.
var tick = 0;
var numbers = Extensions.Generate(30, () => ++tick);
지역변수 tick을 캡쳐하여 표현된 람다식은 다음 클로저 객체로 생각될 수 있다.
private class Closure
{
public int capturedTick;
public int capturedTickFunc() => ++capturedTick;
}
var c = new Closure();
c.capturedTick = 0;
var numbers = Extensions.Generate(30, new Func<int>(c.capturedTickFunc));
지연 평가를 기반으로하는 쿼리식을 사용했을 경우 주의해야할 점이 있다.
public static IEnumerable<string> ParseFile(string path)
{
using (var r = new StreamReader(File.OpenRead(path))
{
var line = r.ReadLine();
while (line != null)
{
yield return line;
line = r.ReadLine();
}
}
}
위와 같이 지연 평가를 사용하는 IEnumerable<T>를 반환하는 이터레이터 메서드가 있다. 함수 내에서 Dispose()가 가능한 File을 열어서 한 줄 씩 읽어 원소를 생성한다.
해당 코드는 한번의 순회를 끝마칠 때마다 Stream 객체를 리소스 해제할 것이다. 해당 리소스가 무겁다면 순회를 할 때마다 그러한 객체를 먼저 생성하고 해제할 것이다.
가능한 방법으로는 ToList()와 같이 즉시 평가를 한번해준 컬렉션을 계속 이용하는 방법이 있다.
일반적으로 쿼리식 내에서 람다 표현식을 자주 사용한다. 람다 표현식은 해당 구문에서 값비싼 리소스를 캡쳐하고 해당 리소스의 생명 주기를 길게하여 성능 저하를 유발 시킬 수 있다.
private static IEnumerable<int> HeavyClosure(int mod)
{
var filter = new ResourceHogFilter();
var source = new SourceGenerator();
var results = new ResultGenerator();
var importantStats = (from num in source.GetNumbers(50)
where filter.Pass(num)
select num).Average();
return from num in results.GetNumbers(100)
where num > importantStats
selet num;
}
무거운 리소스 filter 객체를 첫번째 쿼리 표현식의 람다에서 캡쳐하고 있다. 해당 쿼리식은 지연 평가되므로 importantStats가 해제될 때까지 리소스 참조는 살아있게 된다. 마지막으로 두번째 쿼리 표현식의 모두 순회되고 함수가 종료되어야 filter 리소스가 해제될 것이다.
그러한 리소스가 비관리 리소스를 포함하고 Dispose()를 반드시 호출해야한다면 람다 내로 캡쳐되는 리소스를 언제 해제해야하는 것인지 상황이 복잡해진다. 따라서 캡쳐된 리소스를 면밀히 주시하고 더 이상 사용되지 않을 시점을 판단하여 Dispose()를 호출해야할 것이다.
아이템 42 : IEnumerable<T> 데이터 소스와 IQueryable<T>데이터 소스를 구분하라
var q = from c in dbContext.Customers
where c.City == "London"
select c;
var finalAnswer = from c in q
orderby c.Name
select c;
쿼리식으로만 표현되어 얻어진 결과는 IQueryable<T> 인터페이스를 갖는 객체이다. 첫번째 표현은 LINQ to SQL로 처리되는 작업으로 데이터 베이스에서 주로 작업된다. IEnumerable<T>로 객체를 변환해서 사용하지 않는 이상 두번째 구문까지 처리하는데 지연 평가를 유지하며 모두 데이터 베이스에서 작업된다.
var q = (from c in dbContext.Customers
where c.City == "London"
select c).AsEnumerable();
var finalAnswer = from c in q
orderby c.Name
select c;
두번째 예에서는 첫번째 쿼리식의 결과를 IEnumerable<T>로 변환한다. 첫번째 쿼리 결과에 대해 로컬 메모리에서 데이터를 다루며 두번째 쿼리식은 orderby 정렬 작업은 데이터베이스가 아닌 로컬 머신에서 이루어진다.
IEnumerable<T>확장 메서드는 쿼리식 내의 람다 표현식과 함수 매개변수를 나타내기 위해서 델리 게이트를 사용한다. IQueryable<T>의 경우 같은 표현이더라도 데이터 베이스 쿼리 처리를 나타내는 표현식 트리를 생성해낸다.
쿼리식 내에 일반 메서드를 호출하고 있다면 해당 요소가 IEnumerable<T>의 구현체가 되도록 강제해야한다.
private bool isValidProduct(Product p) => p.ProductName.LastIndexOf('C') == 0;
var q1 = from p in dbContext.Products.AsEnumerable()
where isValidProduct(p)
select p;
// isValidProduct() 함수는 로컬에서만 호출할 수 있기 때문에
// 순회시 예외가 발생한다.
var q2 = from p in dbContext.Products
where isValidProduct(p)
select p;
AsEnumerable() 를 사용하면 쿼리를 로컬 머신에서 강제로 수행하며 LINQ to Objects 를 이용하여 수행된다.
두번째 쿼리식은 LINQ to SQL이 IQueryable<T> 인터페이스를 사용하기 때문에 예외를 일으킨다. LINQ to SQL은 쿼리를내부적으로 T-SQL로 변환하는 IQueryProvider 구현체를 포함하고 있는데 변환된 T-SQL은 원격지의 데이터베이스 엔진에 전달되어 SQL구문을 수행한다.
명시적으로 IEnumerable<T>로 변환하면 예외를 피할 수 있으나 로컬에서 변환된 데이터를 나머지 쿼리식에 적용되므로 성능상 단점이 있다.
데이터 소스가 IQueryable<T>를 구현한다면 반드시 이를 이용하는 것이 좋다. 특정 메서드에 대해 IEnumerable<T>와 IQueryable<T>를 모두 지원하고 싶다면 다음과 같이 작성할 수 있다.
public static IEnumerable<Product> ValidProducts(this IEnumerable<Product> products)
=> from p in products
where p.ProductName.LastIndexOf('C') == 0
select p;
// LINQ to SQL 제공자는 string.LastIndexOf()를 지원한다
public static IQueryable<Product> ValidProducts(this IQueryable<Product> products)
=> from p in products
where p.ProductName.LastIndexOf('C') == 0
select p;
코드 중복없이 AsQueryable()을사용하여 IEnumerable<T>를 IQueryable<T>로 변경하면 같은 기능을 제공할 수 있다.
public static IEnumerable<Product> ValidProducts(this IEnumerable<Product> products)
=> from p in products.AsQueryable()
where p.ProductName.LastIndexOf('C') == 0
select p;
AsQueryable()은 해당 타입을 확인하여 IQueryable<T>의 구현체라면 원격지에서 처리하는 이점을 그대로 이용할 수 있으며 IEnumerable<T>의 구현체의 경우 IQueryable<T>로 래핑된 객체가 반환되어 IEnumerable<T>처럼 로컬에서 동작하는 정상적인 수행이 가능하다.
아이템 43. 쿼리 결과의 의미를 명확히 강제하고 Single()과 First()를 사용하라
Single()은 쿼리 결과를 즉시 평가하여 단일 요소를 반환한다.
var answer = (from log in db.MyLogs
where log.date.Month == 10
select log).Single();
해당 결과가 단일 요소가 아니라면 없거나 하나 이상인 경우 InvalidOperationException()을 던진다.
그 결과가 하나이거나 없다면 SingleOrDefault()를 사용할 수 있다.
First()의 경우 즉시 평가하여 가장 첫번째 원소를 반환한다.
var theFirst = from t in transactions
where t.Amount > 10000
orderby t.Amount
select t).First();
정렬한 요소 중 가장 첫번째 데이터를 가져온다. 데이터가 없다면 예외를 던진다. FirstOrDefault()를 사용하면 결과가 없을 때 null을 반환한다.
이러한 메서드를 사용하는 이유는 사용자가 해당 처리 결과에 대한 조건을 강제하기 위함이다. 반드시 하나 존재해야하거나 가장 첫번째의 중복되지 않은 하나만을 가져오고 싶을 때 사용하도록 한다.
아이템 44. 바인딩된 변수는 수정하지 말라
쿼리문에서 자주 사용되는 람다 함수내에 캡쳐되는 지역 변수를 변경할 경우 지연 수행과 컴파일러의 클로저 구현 때문에 예상하지 않은 결과가 발생할 수 있다.
public static void Main(string[] args)
{
int[] arr = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int value = 0;
var result = arr.Where((v) => v >= 5).Select((v) =>
{
++value;
return v + 5;
});
foreach(var r in result)
{
Console.WriteLine($"Result : {r}");
}
Console.WriteLine($"Value : {value}");
}
value 지역 변수는 람다 내로 캡쳐되어 쿼리식의 결과로 반환된 IEnumerable 구현체가 순회할때마다 람다 함수의 대리자를 호출하고 그 과정에서 값이 수정된다.
지연 평가로 인해 캡쳐된 변수가 언제 수정될 지 모르며 얼마나 수정될 지도 알기 어렵다. 따라서 바인딩된 변수는 수정하지 않도록 주의해야한다.
출처 : Effective C# 빌 와그너 저 - 한빛 미디어
'C# > Advanced C#' 카테고리의 다른 글
[More Effective C#] 1장 데이터 타입 (0) | 2021.12.04 |
---|---|
[Effective C#] 5장 예외 처리 (0) | 2021.11.27 |
[Effective C#] 4장 LINQ 활용 (1) (0) | 2021.11.24 |
[Effective C#] 3장 제너릭 활용 (0) | 2021.11.20 |
[C#] Task (3) async / await 동작 더 알아보기 (0) | 2021.11.18 |