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

C# 모나드 설계 패턴 소개

by bantomak 2024. 4. 9.

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>>과 같은 부적절한 결과가 발생하는 것을 방지하기 위한 것이다.

 

함께 읽으면 좋은 글

 

3분 모나드 | overcurried

주위에 함수형 프로그래머가 있으시다면, 하다못해 함수형 프로그래밍 커뮤니티 근처라도 가보셨다면, 한 번쯤은 꼭 들어 보셨을 법한 개념이 있습니다. 모나드(monad)요. 함수형 프로그래머들은

overcurried.com

 

Functors, Applicatives, And Monads In Pictures - adit.io

Functors, Applicatives, And Monads In Pictures Written April 17, 2013 updated: May 20, 2013 Here's a simple value: And we know how to apply a function to this value: Simple enough. Lets extend this by saying that any value can be in a context. For now you

www.adit.io

출처

 

Functional C# - 예스24

C# 개발자를 위한 함수형 프로그래밍 학습서다. 명령형 프로그래밍 방식과 함수형 프로그래밍을 비교하고, 함수형 프로그래밍을 위한 C#의 언어적 지원과 이를 이용한 실제 구현 예를 살펴보면

www.yes24.com

댓글