14.1 람다식( Lambda Expression, λ-Expression)
람다식은 알론조 처치(Alonzo Church)라는 수학자가 1936년에 발표한 람다 계산법(Lambda Calculus)에서 사용하는 식이다.
알론조 처치는 수학 기초론을 연구하던 중에 분명하고 간결한 방법으로 함수를 묘사하기 위해 람다 계산법을 고안해냈다.
람다 계산법은 크게 함수의 정의와 변수, 함수의 적용으로 이루어지는데 이 계산법에서는 모든 것이 함수로 이루어져 있다.
0, 1, 2, ...와 같은 숫자도 함수로 표현한다. 따라서 람다 계산법에서 어떤 값을 변수에 대입하고 싶으면 함수를 변수에 대입하며, 이것을 함수의 적용이라고 부른다.
람다 계산법은 단순히 수학 이론에 그치지 않았다. 알론조 처치의 제자였던 존 메카시(John McCarthy)가 이것을 프로그래밍 언어에 도입할 수 있겠다는 아이디어를 냈고, 1950년대 말에 LISP라는 언어를 만들었다. 그리고 람다 계산법의 개념은 이후 다른 프로그래밍 언어에도 도입됐으며, C#뿐만 아니라 C++, 자바, 파이썬 같은 주류 프로그래밍 언어는 대부분 람다식을 지원하고 있다.
람다의 뜻은 뭘까?
λ는 그리스 문자에서 L에 해당하는 문자이다. 알론조 처치는 이 문자를 함수 표기를 위한 기호로 사용했는데, 원래는 λ가 아닌 ^였다. 당시에는 ^를 인쇄하기 어려웠기 때문에 λ로 바꾼 것이다.
14.2 처음 만나는 람다식
람다식은 익명 메소드를 만들기 위해 사용한다. 다만 람다식으로 만드는 익명 메소드는 무명 함수(Anonymouse Function)라는 이름으로 부른다. 메소드는 입력(매개변수)과 출력(반환값)을 갖고 있듯이 람다식도 마찬가지다.
기본적인 람다식을 선언하는 형식은 다음과 같다.
매개변수_목록 => 식
'=>' 연산자는 '입력' 연산자이다. 이 연산자가 하는 역할은 그저 매개변수를 전달하는 것뿐이다. 람다식에서는 =>를 중심으로 왼편에는 매개변수가, 오른편에는 식이 위치한다.
delegate int Calculate(int a, int b); // 익명 메소드를 만들려면 대리자가 필요하다.
//...
static void Main(string[] args)
{
// 두 개의 int 타입 매개변수 a, b를 받아 이 둘을 더해 반환하는 익명 메소드를 람다식으로 만들었다.
Calculate calc = (int a, int b) => a+b;
}
매개변수 목록과 계산 코드(이 계산 코드의 결과는 곧 반환값이 된다), 그리고 매개변수를 계산 코드 안으로 입력시키는 => 연산자로만 이루어진다.
C# 컴파일러는 이 코드를 한층 더 간결하게 만들 수 있도록 '타입 유추(Type Inference)'라는 기능을 제공한다. 타입 유추를 이용하면 다음과 같이 앞에 나온 예제 코드의 람다식에서 매개변수의 타입을 제거할 수 있다.
delegate int Calculate(int a, int b);
// ...
static void Main(stringp[] args)
{
/*
C# 컴파일러는 Calculator 대리자의 선언 코드로부터
이 람다식이 만드는 익명 메소드의 매개변수 타입을 유추해낸다.
*/
Calculate calc = (a,b) => a+b;
}
대리자를 이용해서 익명 메소드를 만들던 것을 생각해보면 코드의 양이 상당히 많이 줄었다. 다음은 비교를 위해 대리자를 이용했을 때의 코드이다.
람다식은 대리자보다 더 간결하다. 적응되면 익명 메소드는 람다식만 써서 만들 것이다.
근데 어째서 더 번거로운 방법(대리자)과 더 편리한 방법(람다식)을 같이 제공할까?
마이크로소프트는 대리자를 이용한 익명 메소드를 C# 2.0에 도입했는데, 람다식은 C# 3.0에 와서야 도입했기 때문이다. 수많은 프로그램이 C# 2.0으로 작성된 상황에서 C# 3.0이 나왔다고 언어의 기능을 뺄 수는 없기 때문이다.
14.3 문 형식의 람다식
람다식은 말 그대로 '식(Expression)' 형식을 하고 있었다.
a + b나 a==b 말고
if(a==b)
return 0;
else
return 1;
같은 문장을 사용할 순 없을까? 가능하다.
문 형식의 람다 식(Statement Lambda)은 => 연산자의 오른편에 식 대신 중괄호 {와 }로 둘러싸인 코드 블록이 위치한다.
(매개변수_목록) => {
문장1;
문장2;
문장3;
...
}
다음은 문 형식의 람다식 예제이다. 이 예제에서는 반환 타입과 매개변수가 없는 대리자를 사용하고 있다. 식 형식의 람다식으로는 반환 타입이 없는 무명 함수를 만들 수 없지만, 문 형식의 람다식을 이용하면 가능하다.
delegate void DoSomething();
// ...
static void Main(string[] args)
{
// 매개변수가 없는 경우에는 ()에 아무것도 넣지 않는다.
DoSomething DoIt = () =>
{
Console.WriteLine("Hello");
Console.WriteLine("World");
}; // 문장 형식의 람다식은 {와 }로 둘러싼다.
DoIt();
}
14.4 Func와 Action으로 더 간편하게 무명 함수 만들기
익명 메소드와 무명 함수는 코드를 더 간결하게 만들어주는 요소이다. 이것들을 선언하기 전에 해야 하는 작업을 생각해보자. 대부분 단 하나의 익명 메소드나 무명 함수를 만들기 위해 매번 별개의 대리자를 선언해야 한다. 번거로운 일이다. 이 문제를 해결하기 위해 마이크로소프트는 .NET에 Func와 Action 대리자를 미리 선언해뒀다.
Func 대리자는 결과를 반환하는 메소드를, Action 대리자는 결과를 반환하지 않는 메소드를 참조한다.
1. Func 대리자
Func 대리자는 결과를 반환하는 메소드를 참조하기 위해 만들어졌다. .NET에는 모두 17가지 버전의 Func 대리자가 준비되어 있는데, 대략 다음과 같다.
public delegate TResult Func<out TResult>()
public delegate TResult Func<in T, out TResult>(T arg)
public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2)
public delegate TResult Func<in T1, in T2, in T3, out TResult>(T1 arg1, T2 arg2, T3 arg3)
...
public delegate TResult Func<in T1, in T2, in T3, ..., in T15, out TResult>(T1 arg1, T2 arg2, T3 arg3, ..., T15 arg15)
public delegate TResult Func<in T1, in T2, in T3, ..., in T15, in T16, out TResult>(T1 arg1, T2 arg2, T3 arg3, .., T15 arg15, T16 arg16)
모든 Func 대리자의 타입 매개변수 중 가장 마지막에 있는 것이 반환 타입이다. 타입 매개변수가 하나뿐인 Func는 그 하나가 반환 타입이고, 타입 매개변수가 두 개인 Func는 두 번째, 세 개인 Func는 세 번째, 그리고 17개인 Func는 열일곱 번째가 반환 타입으로 사용된다.
Func 대리자는 입력 매개변수가 하나도 없는 것부터 16개에 이르는 것까지 버전이 다양하기 때문에 어지간한 경우(입력 매개변수가 16개 이상이라든가, ref나 out 한정자로 수식된 매개변수를 사용해야 하는 경우)가 아니면 별도의 대리자를 만들어 쓸 필요가 없다.
입력 매개변수가 없는 버전, Func<TResult>의 사용 예이다.
Func<int> func1 = () => 10; // 입력 매개변수는 없으며, 무조건 10을 반환
Console.WriteLine(func1()); // 10 출력
매개변수가 하나 있는 버전, Func<T1, TResult>의 사용 예이다.
Func<int, int> func2 = (x) => x*2; // 입력 매개변수는 int 타입 하나, 반환 타입도 int
Console.WriteLine(func2(3)); // 6 출력
마지막으로 매개변수가 두 개 있는 버전, Func<T1, T2, TResult>의 사용 예이다.
Func<int, int, int> func3 = (x, y) => x + y; // 입력 매개변수는 int 타입 둘, 반환 타입은 int
Console.WriteLine(func3(2,3)); // 5 출력
2. Action 대리자
Action 대리자는 Func 대리자와 거의 똑같다. 차이점이라면 Action 대리자는 반환 타입이 없다는 것뿐이다. Action 대리자도 Func 대리자처럼 17개 버전이 선언되어 있다.
public delegate void Action<>()
public delegate void Action<in T>(T arg)
public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2)
public delegate void Action<in T1, in T2, in T3>(T1 arg1, T2 arg2, T3 arg3)
...
public delegate void Action<in T1, in T2, in T3, ..., in T14, in T15>(T1 arg1, T2 arg2, T3 arg3, ..., T14 arg14, T15 arg15)
public delegate void Action<in T1, in T2, in T3, ..., in T14, in T15, in T16>(T1 arg1, T2 arg2, T3 arg3, ..., T14 arg14, T15 arg15, T16 arg16)
Action 대리자의 타입 매개변수는 모두 입력 매개변수를 위해 선언되어 있다. Func와 달리 어떤 결과를 반환하는 것을 목적으로 하지 않고, 일련의 작업을 수행하는 것이 목적이기 때문이다.
한편 Action 대리자는 매개변수가 없는 버전에서부터 16개나 되는 버전까지 준비되어 있기 때문에 프로그래머에게 필요한 대부분의 경우에 사용할 수 있다.
아무것도 없는 Action의 사용 예이다. 반환 결과는 없다.
Action act1 = () => Console.WriteLine("Action()");
act1();
매개변수가 하나뿐인 버전, Action<T>의 사용 예이다.
int result = 0;
Action<int> act2 = (x) => result = x * x; // 람다식 밖에서 선언한 result에 x*x의 결과를 저장한다.
act2(3);
Console.WriteLine("result : {0}", result); // 9 출력
매개변수가 두 개인 Action<T1, T2>의 사용 예이다.
Action<double, double> act3 = (x, y) =>
{
double pi = x/y;
Console,WriteLine("Action<T1, T2>({0}, {1}) : {2}", x, y, pi);
};
act3(22.0, 7.0);
14.5 식 트리
트리는 다음과 같이 노드(Node)로 구성되며, 각 노드는 서로 부모-ㅣ자식 관계로 연결된다.
평범한 트리 자료구조에서는 부모 노드가 여러 개의 자식 노드를 가질 수도 있지만, 식 트리는 한 부모 노드가 단 두개만의 자식 노드를 가질 수 있는 이진 트리(Binary Tree)이다.
식 트리(Expression Tree)란 식을 트리로 표현한 자료구조를 말한다. 1*2+(7-8)이라는 식을 식 트리로 표현하면 다음과 같다.
식 트리에서 연산자는 부모 노드가 되며, 피연산자는 자식 노드가 된다.
이렇게 식 트리로 표현된 식은 트리의 잎 노드부터 계산해서 루트까지 올라가면 전체 식의 결과를 얻을 수 있다.
식 트리 자료구조는 컴파일러나 인터프리터를 제작하는 데도 응용된다. 컴파일러는 프로그래밍 언어의 문법을 따라 작성된 소스 코드를 분석해서 식 트리로 만든 후 이를 바탕으로 실행 파일을 만든다.
완전한 C# 컴파일러는 아니지만, C#은 프로그래머가 C# 코드 안에서 직접 식 트리를 조립하고 컴파일해서 사용할 수 있는 기능을 제공한다. 다시 말해, 프로그램 실행 중에 동적으로 무명 함수를 만들어 사용할 수 있게 해준다는 이야기이다.
식 트리를 다루는 데 필요한 클래스들은 System.Linq.Expressions 네임스페이스 안에 준비되어 있다.
'Expression' 클래스와 그 파생 클래스들이다.
Expression의 파생 클래스 | 설명 |
BinaryExpression | 이항 연산자(+, -, *, /, %, &, |, ^, <<, >>, &&, ||, ==, !=, >, >=, <, <=)를 갖는 식을 표현한다. |
BlockExpression | 변수를 정의할 수 있는 식을 갖는 블록을 표현한다. |
ConditionalExpression | 조건 연산자가 있는 식을 나타낸다. |
ConstantExpression | 상수가 있는 식을 나타낸다. |
DefaultExpression | 타입(Type)이나 비어 있는 식의 기본값을 표현한다. |
DynamicExpression | 동적 작업을 나타낸다. |
GotoExpression | return, break, continue, goto와 같은 점프문을 나타낸다. |
IndexExpression | 배열의 인덱스 참조를 나타낸다. |
InvocationExpression | 대리자나 람다식 호출을 나타낸다. |
LabelExpression | 레이블을 나타낸다. |
LambdaExpression | 람다식을 나타낸다. |
ListInitExpression | 컬렉션 이니셜라이저가 있는 생성자 호출을 나타낸다. |
LoopExpression | 무한 반복을 나타낸다. 무한 반복은 break문을 이용해서 종료할 수 있다. |
MemberExpression | 객체의 필드나 속성을 나타낸다. |
MemberInitExpression | 생성자를 호출하고 새 객체의 멤버를 초기화하는 동작을 나타낸다. |
MethodCallExpression | 메소드 호출을 나타낸다. |
NewArrayExpression | 새 배열의 생성과 초기화를 나타낸다. |
NewExpression | 생성자 호출을 나타낸다. |
ParameterExpression | 명명된 인수를 나타낸다. |
RuntimeVariablesExpression | 변수에 대한 런타임 읽기/쓰기 권한을 제공한다. |
SwitchExpression | 다중 선택 제어 식을 나타낸다. |
TryExpression | try ~ catch ~ finally 문을 나타낸다. |
TypeBinaryExpression | 타입 테스트를 비롯한 타입(Type)과 식(Expression)의 연산을 나타낸다. |
UnaryExpreesion | 단항 연산자를 갖는 식을 나타낸다. |
이 표의 클래스들은 Expression 클래스의 파생 클래스이다.
Expression 클래스는 식 트리를 구성하는 노드를 표현한다. 그래서 Expression을 상속받는 이 표의 클래스들이 식 트리의 각 노드를 표현할 수 있게 된다. 하지만 Expression 클래스는 식 트리를 구성하는 노드를 표현하는 것 외에도, 위의 표에 열거된 클래스들의 객체를 생성하는 역할도 담당한다. Expression 클래스 자신은 abstract로 선언되어 자신의 인스턴스는 만들 수 없지만, 파생 클래스의 인스턴스를 생성하는 정적 팩토리 메소드를 제공한다.
팩토리 메소드
팩토리 메소드(Factory Method)는 클래스의 인스턴스를 생성하는 일을 담당하는 메소드를 가리키는 용어이다.
C#에는 객체를 생성하는 생성자 메소드가 있지만, 가끔은 이것만으로 충분하지 않을 때가 있다.
객체의 생성에 복잡한 논리가 필요한 경우, 객체 생성 과정을 별도의 메소드에 구현해놓으면 코드의 복잡도를 상당히 줄일 수 있다.
Expression 클래스의 정적 팩토리 메소드들은 Expression 클래스의 파생 클래스인 ConstantExpression, BinaryExpression 클래스 등의 인스턴스를 생성하는 기능을 제공함으로써 수고를 줄여준다.
다음은 상수를 표현하는 ConstantExpression 객체 하나와 매개변수를 표현하는 ParameterExpression 객체 하나를 선언하고, 이 둘에 대해 덧셈(+), 연산을 수행하는 BinaryExpression 객체를 선언할 것이다. 물론 이들 객체들은 Expression 클래스의 팩토리 메소드를 통해 생성할 것이다.
Expression const1 = Expression.Constant(1); // 상수 1
Expression param1 = Expression.Parameter(typeof(int), "x") // 매개변수 x
Expression exp = Expression.Add(const1, param1); // 1 + x
첫 번째 줄에서 Expression.Constant() 팩토리 메소드로 ConstantExpression 타입의 const1 객체를 선언하고 있다.
여기서 "ConstantExpression 타입? const1은 Expression 타입으로 선언했는데?" 라는 의문이 들 수 있다.
ConstantExpression은 Expression을 상속받기 때문에 ConstantExpression 객체는 Expression 타입의 참조를 통해 가리킬 수 있다. const1뿐만 아니라 param1은 원래 ParameterExpression의 인스턴스이고, exp는 BinaryExpression의 인스턴스이다. 하지만 ParameterExpression이나 BinaryExpression도 Expression의 파생 클래스이기 때문에 Expression 타입의 참조를 통해 가리킬 수 있다. 덕분에 프로그래머는 각 노드가 어떤 타입인지 신경쓰지 않고 Expression 타입의 참조를 선언해서 사용할 수 있다. 필요한 경우에는 각 세부 타입으로 타입 변환하면 된다. 이게 팩토리 메소드 패턴의 이점이다.
식 트리는 결국 '식'을 트리로 표현한 것이. 다시 말해 위의 exp는 실행가능한 상태가 아닌 그저 '데이터' 상태에 머물러 있다는 것이다. exp가 자신의 트리 자료구조 안에 정의된 식을 실행할 수 있으려면 람다식으로 컴파일되어야 한다.
람다식으로의 컴파일은 다음과 같이 Expression<TDelegate> 클래스를 이용한다.
(Expression<TDelegate>는 앞의 표에도 나타나 있는 LambdaExpression 클래스의 파생 클래스이다).
Expression const1 = Expression.Constant(1); // 상수 1
Expression param1 = Expression.Parameter(typeof(int), "x") // 매개변수 x
Expression exp = Expression.Add(const1, param1); // 1 + x
Expression<Func<int, int>> lambda1 =
Expression<Func<int, int>>.Lambda<Func<int, int>>{
exp, new ParameterExpression[]{
(ParameterExpression)param1});
Func<int, int> compiledExp = lambda1.Compile(); // 실행 가능한 코드로 컴파일
// 컴파일한 무명 함수 실행
Console.WriteLine(compiledExp(3)); // x = 3 이면 1+x=4 이므로 4 출력
using System;
using System.Linq.Expressions;
namespace PracticeCSharp
{
class Program
{
static void Main(string[] args)
{
// 1*2+(x-y)
Expression const1 = Expression.Constant(1);
Expression const2 = Expression.Constant(2);
Expression leftExp = Expression.Multiply(const1, const2); // 1 * 2
Expression param1 =
Expression.Parameter(typeof(int)); // x
Expression param2 =
Expression.Parameter(typeof(int)); // y
Expression rightExp = Expression.Subtract(param1, param2); // x - y
Expression exp = Expression.Add(leftExp, rightExp);
Expression<Func<int, int, int>> expression =
Expression<Func<int, int, int>>.Lambda<Func<int, int, int>>(
exp, new ParameterExpression[]
{
(ParameterExpression)param1,
(ParameterExpression)param2});
Func<int, int, int> func = expression.Compile();
// x = 7, y = 8
Console.WriteLine($"1*2+({7}-{8}) = {func(7, 8)}");
}
}
}
위의 코드를 람다식을 이용하면 더 간편하게 식 트리를 만들 수 있다. 대신 동적으로 식 트리를 만들기는 어려워진다.
Expression 타입은 불변(Immutable)이기 때문에 인스턴스가 한번 만들어지면 변경할 수 없다.
아래는 위의 코드와 같은 식 트리를 만들어서 실행결과가 같다.
using System;
using System.Linq.Expressions;
namespace PracticeCSharp
{
class Program
{
static void Main(string[] args)
{
// 1*2+(x-y)
Expression<Func<int, int, int>> expression =
(a, b) => 1 * 2 + (a - b);
Func<int, int, int> func = expression.Compile();
// x = 7, y = 8
Console.WriteLine($"1*2+{7}-{8} = {func(7, 8)}");
}
}
}
식 트리는 코드를 '데이터'로 보관할 수 있다. 이를 파일에 저장할 수 있고, 네트워크를 통해 다른 프로세스에 전달할 수 있다. 더 나아가 코드를 담고 있는 식 트리 데이터를 데이터베이스 서버에 보내서 실행할 수 있다. 데이터베이스 처리를 위한 식 트리는 LINQ에서 사용된다.
14.6 식으로 이루어지는 멤버
메소드를 비롯해 속성 (인덱서), 생성자, 종료자는 공통된 특징이 있다.
모두 클래스의 멤버로서 본문이 중괄호{}로 만들어져 있다. 이러한 멤버의 본문을 식(Expression)만으로 구현할 수 있는데, 이렇게 식으로 구현된 멤버를 영어로 'Expression-Bodied Member'라고 하고, '식 본문 멤버' 라고 한다.
식 본문 멤버의 문법은 다음과 같다.
멤버 => 식;
class FriendList
{
private List<string> list = new List<string>();
// 나머지 구현
}
메소드를 호출하는 식으로 이루어진다.
class FriendList
{
private List<string> list = new List<string>();
// 각각 메소드를 호출하는 식으로 이루어짐
public void Add(string name) => list.Add(name);
public void Remove(string name) => list.Remove(name);
}
생성자와 종료자도 식으로 구현한다.
class FriendList
{
// ...
// 생성자와 종료자를 식으로 구현
public FriendList() => Console.WriteLine("FriendList()"); // 생성자
~FriendList() => Console.WriteLine("~FriendList()"); // 종료자
}
읽기 전용 속성과 인덱서를 식으로 구현한다. get 키워드 생략 가능하다.
class FriendList
{
// ...
// 읽기 전용 속성과 인덱서를 식으로 구현, get 키워드 생략 가능
public int Capacity => list.Capacity; // 읽기 전용 속성
public string this[int index] => list[index] // 읽기 전용 인덱서
}
읽기/쓰기 모두 가능한 속성 또는 인덱서를 구현하려면 get(set) 키워드를 명시적으로 기술해야 한다.
class FriendList
{
// ...
public int Capacity // 속성
{
get => list.Capacity;
set => list.Capacity = value;
}
public string this[int index] // 인덱서
{
get => list[index];
set => list[index] = value;
}
}
'공부 > C#' 카테고리의 다른 글
이것이 C#이다 Chapter 16 리플렉션과 애트리뷰트 (0) | 2024.03.17 |
---|---|
이것이 C#이다 Chapter 15 LINQ (0) | 2024.03.16 |
이것이 C#이다 Chapter 12 예외 처리하기 (0) | 2024.03.09 |
이것이 C#이다 Chapter 11 일반화 프로그래밍 (0) | 2024.03.09 |
이것이 C#이다 Chapter 10 배열과 컬렉션 그리고 인덱서 (6) | 2024.03.08 |