C# 모나드 설계 패턴
C#에서 제공하는 Nullable<T>, IEnumerable<T>, Func<T>, Lazy<T>, Task<T>가 여기에 해당한다.
이 다섯 형식은 모두 T라는 하나의 매개 변수를 가지는 제네릭 형식이라는 것을 포함해 몇 가지 공통점을 가진다. 이들은 특정 동작과 연계된 특별한 규칙들을 제공하는 측면, 즉, 형식에 대한 증폭기와 같은 역할을 함으로써 자연스럽게 모나드를 구현한다. 이 형식들은 어떤 형식을 취해 특수한 형식으로 변환해 준다.
Nullable<T>
Nullable<T>는 예를 들어, -2,147,483,648 ~ 2,147,483,647 사이의 정수만 담을 수 있는 int 형식이 null에 대응할 수 있게 확장해 준다. 즉, null에 대응하도록 기능을 확장시켜 주는 래퍼 클래스라고 생각하면 이해하기 쉽다.
private static Nullable<int> WordToNumber(string word)
{
Nullable<int> returnValue;
if (word == null)
{
return null;
}
switch (word)
{
case "zero": returnValue = 0;
break;
case "one":
returnValue = 0;
break;
case "two":
returnValue = 0;
break;
case "three":
returnValue = 0;
break;
case "four":
returnValue = 0;
break;
case "five":
returnValue = 0;
break;
default:
returnValue = null;
break;
}
return returnValue;
}
이 코드는 string 형식으로 저장된 숫자를 int 형식으로 변환한다. 하지만 string 형식과 달리 int 형식은 null을 처리하지 못하기 때문에 제대로 처리하기 어렵다. 이에 대한 해결책으로 Nullable<int>를 반환 형식으로 사용함으로써 다음과 같이 null을 반환할 수 있다.
Nullable<int> returnValue;
if (word == null)
{
return null;
}
다음으로 WordToNumber() 함수를 호출하는 PrintStringNumber()는 다음과 같다.
private static void PrintStringNumber(string stringNumber)
{
if (stringNumber == null && WordToNumber(stringNumber) == null)
{
Console.WriteLine("Word: null is Int: null");
}
else
{
Console.WriteLine($"Word: {stringNumber.ToString()} is Int: {WordToNumber(stringNumber)}");
}
}
Nullable 형식의 int 데이터 형식을 사용하므로 이제 다음처럼 null 반환에 대한 처리가 가능하다.
이 코드는 WordToNumber()에 null 문자열을 전달한 경우에 대응 가능하며, 다음과 같이 PrintStringNumber()를 문제없이 호출할 수 있다.
이처럼 int 형식에서 null 값을 사용할 수 있게 된 것은 모나드 패턴 덕분이라 할 수 있다.
IEnumerable<T>
IEnumerable<T> 역시 T 형식의 기능을 확장해 준다. 예를 들어 string 형식을 열거하거나 정렬하기 위해 IEnumerable<T>를 적용한다고 한다면 다음과 같이 적용할 수 있다.
private static void AmplifyString()
{
IEnumerable<string> stringEnumerable = YieldNames();
Console.WriteLine("Enumerate the stringEnumerable");
foreach(string s in stringEnumerable)
{
Console.WriteLine($"- {s}");
}
IEnumerable<string> stringSorted = SortAscending(stringEnumerable);
Console.WriteLine();
Console.WriteLine("Sort the stringEnumerable");
foreach(string s in stringSorted)
{
Console.WriteLine($"- {s}");
}
}
AmplifyingString() 함수를 통해 string 형식을 확장한 열거 가능한 문자열을 초기화하면서 여러 개의 값을 저장하거나 열거, 정렬할 수 있다는 것을 보여준다.
열거 가능한 문자열을 초기화하기 위해 사용한 YieldNames() 함수는 다음과 같다.
private static IEnumerable<string> YieldNames()
{
yield return "Nichilas Shaw";
yield return "Anthony Hammond";
yield return "Desiree Waller";
yield return "Gloria Allen";
yield return "Daniel McPherson";
}
다음은 정렬에 사용한 SortAsecending() 함수다.
private static IEnumerable<string> SortAscending(IEnumerable<string> enumString)
{
return enumString.OrderBy(x => x);
}
YieldNames() 함수는 다섯 개의 이름을 반환한다. 이들 이름은 IEnumerable<string> 형식의 stringEnumerable 변수에 저장된다. 이때 stringEnumerable이 여러 개의 값을 처리할 수 있게 사용되고 있음을 확인할 수 있다. 그리고 SortAscending()에서는 stringEnumerable을 활용해서 순서에 따라 정렬한다.
이처럼 string 형식의 기능을 확대함으로써 여러 개의 문자열 값을 열거하거나 정렬할 수 있다.
Func<T>
Func<T>는 매개 변수 전달 없이 T형식의 값을 반환하는 메서드를 캡슐화한다. 이 목적에 부합하게 다음과 같은 Func<T> 메서드를 만들어보겠다.
Func<int> MultipliedFunc;
MultipliedFunc는 매개 변수를 갖기 않으며 int 값을 반환하는 함수의 대리자다. 다음 코드는 Func<T> 역시 모나드를 자연스럽게 구현하고 있다는 것을 설명한다. 하지만 먼저 Nullable 형식을 이용해서 래퍼를 하나 만들겠다.
public static Nullable<int> MultipliedByTwo(Nullable<int> nullableInt)
{
if (nullableInt.HasValue)
{
int unWrappedInt = nullableInt.Value;
int multipliedByTwo = unWrappedInt * 2;
return GetNullableFormatInt(multipliedByTwo);
}
else
{
return new Nullable<int>();
}
}
private static Nullable<int> GetNullableFormatInt(int iNumber)
{
return new Nullable<int>(iNumber);
}
MultipliedTwo() 함수는 간단하며, 곱하기 처리를 위해 래핑을 해체하고 계산 결과는 다시 래핑 하는 과정을 거친다.
private static void RunMultipliedByTwo()
{
for (int i = 1; i <= 5; i++)
{
Console.WriteLine($"{i} multiplied by two is equal to {MultipliedByTwo(i)}");
}
}
결과 화면을 보면 이 함수에서 일반적인 패턴이 보일 것이다. 래핑이 해제된 1,2,3,4,5에 2를 곱하고, 결과인 2, 4, 6, 8, 10에 대한 래핑이 이뤄진다.
이제 Func<T>에 대해 살펴보자. 다음 GetFuncFromInt() 함수는 Func<int> 형식을 반환한다.
private static Func<int> GetFuncFromInt(int iItem)
{
return () => iItem;
}
GetFuncFromInt() 함수는 int 값을 이용해 새로운 Func<T> 메서드를 생성한다. 여기서 다른 시그니처를 갖는 MutipliedByTwo() 함수를 추가로 만들어보자.
private static Func<int> MultipliedByTwo(Func<int> funcDelegate)
{
int unWrappedFunc = funcDelegate();
int multipliedByTwo = unWrappedFunc * 2;
return GetFuncFromInt(multipliedByTwo);
}
이 코드는 잘 컴파일되겠지만, 여기서 다음과 같은 코드를 고려해 보자.
private static void RunMultipliedByTwoFunc()
{
Func<int> intFunc = MultipliedByTwo(() => 1 + 1);
}
RunMultipliedByTwoFunc()를 실행하면 (1 + 1) * 2라는 공식이 아닌 고정된 값 4를 얻을 것이다. 이것은 다음과 같이 해결할 수 있다.
private static Func<int> RunMultipliedByTwoFunction(Func<int> funcDelegate)
{
return () =>
{
int unWrappedFunc = funcDelegate();
int multipliedByTwo = unWrappedFunc * 2;
return multipliedByTwo;
};
}
MultipliedByTwoFunction()의 경우, 값을 요구할 때마다 원본 함수 대리자 값이 유지된다. 이전 코드의 경우에는 래핑이 해제된 값을 이용해서 연산이 수행된다. Nullable<int>와 Func<int>는 래핑 된 형식의 결과가 생성되는 방식 등에서 차이가 있다. Nullable 모나드를 이용하는 경우, 래핑을 해제한 값을 직접적으로 사용해서 연산을 수행하고 마지막으로 래핑 된 값을 산출할 수 있다. 반면 Func 모나드를 유지하기 위해 대리자를 생성해야 하기 때문에 조금 더 세심하게 신경 써야 한다.
모나드에서는 보다시피 래핑 된 int에 2를 곱하면 함수가 또 다른 래핑 된 int 값을 생성할 수 있으므로 증폭이라고 부를 수 있다.
모나드 M<T> 형식 만들기
이제부터 앞에서 살펴본 코드를 리팩터링 해서 모나드를 좀 더 우아하게 구현해 보겠다.
private static Nullable<int> MultipliedByTwoFunction(Nullable<int> iNullable, Func<int, int> funcDelegate)
{
if (iNullable.HasValue)
{
int unWrappedInt = iNullable.Value;
int multipliedByTwo = funcDelegate(unWrappedInt);
return new Nullable<int>(multipliedByTwo);
}
else
{
return new Nullable<int>();
}
}
이 메서드에서는 정수 인수 하나를 전달받아 정수를 반환하는 Func<int, int>를 사용하며, 추가로 Nullable<int> 매개 변수를 직접적으로 인수를 통해 받는다. 그리고 다음 MultipliedByTwo() 함수와 같이 2를 곱한 값을 얻을 수 있다.
private static Nullable<int> MultipliedByTwo(Nullable<int> iNullable)
{
return MultipliedByTwoFunction(iNullable, x => x * 2);
}
private static void RunMultipliedByTwo()
{
Console.WriteLine("RunMultipliedByTwo() implementing higher-order programming");
for (int i = 1; i <= 5; i++)
{
Console.WriteLine($"{i} multiplied by two is equal to {MultipliedByTwo(i)}");
}
}
다음은 RunMultipliedByTwo()를 호출한 결과다.
모나드에 제네릭 데이터 형식 구현하기
다음과 같이 MultipliedByTwo()를 좀 더 일반화하기 위해 제네릭을 적용할 수 있다.
private static Nullable<T> MultipliedByTwoFunction<T>(Nullable<T> iNullable, Func<T, T> funcDelegate) where T : struct
{
if (iNullable.HasValue)
{
T unWrappedValue = iNullable.Value;
T multipliedByTwo = funcDelegate(unWrappedValue);
return new Nullable<T>(multipliedByTwo);
}
else
{
return new Nullable<T>();
}
}
어떤 이유로 int 값을 전달하지만 결과는 double 형식으로 반환하는 함수를 사용하거나 정수를 나누는 등의 작업을 하고자 한다면, 다음과 같이 함수를 수정하면 된다.
private static Nullable<R> MultipliedByTwoFunction<V, R>(Nullable<V> iNullable, Func<V, R> funcDelegate) where V : struct where R : struct
{
if (iNullable.HasValue)
{
V unWrappedValue = iNullable.Value;
R multipliedByTwo = funcDelegate(unWrappedValue);
return new Nullable<R>(multipliedByTwo);
}
else
{
return new Nullable<R>();
}
}
MultipliedByTwoFunction() 메서드는 특정 형식의 값을 전달하면 이것을 증폭된 형식의 값으로 변환하기 때문에 모나드 패턴을 사용한다고 할 수 있다. 다시 말해, 이 함수는 V를 입력받아 R을 반환하는 함수를 증폭된 형식인 M<V>를 받아 M<R>을 반환하는 함수로 바꿔주는 패턴을 구현하고 있다. 따라서 모나드 패턴이 적용된 메서드를 다음과 같이 표현할 수 있다.
private static M<R> MonadFunction<V, R>(M<V> amplified, Func<V, R> function)
{
// 구현
}
이제 함수에서 모나드 패턴을 구현할 때는 모나드 M<T> 형식을 이용할 수 있다. MultiPliedByTwoFunction<V, R>() 메서드는 다음과 같이 추가로 개선의 여지가 있다.
private static Nullable<R> MultipliedByTwoFunctionSpecial<V, R>(Nullable<V> iNullable, Func<V, Nullable<R>> funcDelegate) where V : struct where R : struct
{
if (iNullable.HasValue)
{
V unWrappedValue = iNullable.Value;
Nullable<R> multipliedByTwo = funcDelegate(unWrappedValue);
return multipliedByTwo;
}
else
{
return new Nullable<R>();
}
}
코드를 보면, 두 번째 매개 변수가 Func<V, R>에서 Func<V, Nullable<R>>로 바뀌었다. 이것은 Nullable<R>을 변환하기를 기대하는데 Nullable<Nullable<R>>과 같은 부적절한 결과가 발생하는 것을 방지하기 위한 것이다.
함께 읽으면 좋은 글
출처
'프로그래밍 > C#' 카테고리의 다른 글
ASP.NET Core 호스트 설정하기 (0) | 2024.04.15 |
---|---|
C# 비동기 프로그래밍으로 반응성 개선하기 (0) | 2024.04.11 |
.NET Options Pattern 사용하기 (0) | 2024.04.09 |
상황별로 appsettings.json 선택적으로 읽기 (0) | 2024.04.08 |
C# 메모화(Memoization) (0) | 2024.04.05 |
댓글