FP 연습 예제

그럴 때가 있잖습니까?

나름 OCP 를 준수하려고 노력했는데, 인터페이스 자체가 바뀌여야 하는 상황.

INameValidator
{ 
   // 2주 전
   // bool Validate(string name);   

   // 1주 전
   // Task<bool> ValidateAsync(string name);
   
   // 이번 주
   Task<ValidationResult> ValidateAsync(string name); 
}

또 그럴 때도 있잖습니까?

메서드 하나만 넣으면 되는데, 보일러 플레이트 코드가 뭔가 많아 보일 때.

class NullValidator : INameValidator { }
class KoreanNameValidator : INameValidator { }
class LengthValidator : INameValidator { }
// ...

누군가, 이런 경우에, 함수형 기법이 좋다고 하더라구요.

그래서, 함 만들어 봤습니다.

BigSquareHasNoEdge/Transcritor: Transcribes a language into another phonetically.

아직은 익숙하지 않아서 그런가 뭐가 좋다고 딱 잘라 말할 수 없는데, 한 가지 장점을 꼽으라면, 데이터를 사용하는 시점에, 용도에 맞는 메서드(함수)를 추가하는 것이 매우 자유롭습니다. (기존 코드 수정하지 않아도 됩니다.)

5개의 좋아요

인터페이스를 바꾸는 경우라면 모던 C#에서는 기본 인터페이스 메서드를 사용할 수도 있습니다. :sweat_smile:

2개의 좋아요

덕분에 인터페이스 기본 구현에 대한 적절한 용도를 더 잘 알게 된 것 같습니다.

다만, 링크에서 소개된 내용은 인터페이스 구현 코드, 소비 코드를 자유롭게 재작성/빌드가 가능한 경우만 유효할 것 같습니다.

물론 대부분 여기에 해당되지만, 그 모듈이 외부 모듈(다른 업체가 제공한 패키지)이라면, 수정 요청에 대한 대응이 원활하지 않은 경우도 있을 수 있습니다.

FP 는 데이터와 함수를 분리해서 선언하기 때문에, 이러한 제약에서 자유로운 편인데, 이는 소비 코드가 기존 모듈을 변경하지 않고도 형식에 행위를 추가하는 것이 얼마든지 가능하다는 의미가 됩니다.

C#은 FP가 요구하는 데이터 불변성(record), 데이터 형식의 파생, 강력한 형식의 함수(delegate) 를 모두 제공하기 때문에 FP 의 장점도 향유할 수 있습니다.

링크의 예제 중 일부를 FP 로 표현한다면,

namespace Original;
public record CustomerType( // ... );
public record OrderType(//...);

아래는 버전 1입니다.

using Original;
namespace Version1;
public static class Customer
{
   public static decimal ComputeLoyaltyDiscount(CustomerType customer)
   {
      DateTime TwoYearsAgo = DateTime.Now.AddYears(-2);
      if ((customer.DateJoined < TwoYearsAgo) && (customer.PreviousOrders.Count() > 10))
      {
          return 0.10m;
      }
      return 0;
   }
}

아래는 버전1의 소비 코드입니다.

using Version1;
// ...
// Check the discount:
Console.WriteLine($"Current discount: {Customer.ComputeLoyaltyDiscount(c)}");

아래는 버전2입니다.

using Original;
namespace Version2;
public static class Customer
{
   public static void SetLoyaltyThresholds(
      TimeSpan ago,
      int minimumOrders = 10,
      decimal percentageDiscount = 0.10m)
   {
      length = ago;
      orderCount = minimumOrders;
      discountPercent = percentageDiscount;
   }
   private static TimeSpan length = new TimeSpan(365 * 2, 0,0,0); // two years
   private static int orderCount = 10;
   private static decimal discountPercent = 0.10m;

   public static decimal ComputeLoyaltyDiscount(CustomerType customer)
   { 
       DateTime start = DateTime.Now - length;
       if ((customer.DateJoined < start) && (customer.PreviousOrders.Count() > orderCount))
       {
          return discountPercent;
       }
       return 0;
   }
}

소비코드

using Version2;
// ...
Customer.SetLoyaltyThresholds(new TimeSpan(30, 0, 0, 0), 1, 0.25m);
Console.WriteLine($"Current discount: {Customer.ComputeLoyaltyDiscount(c)}");

보시다시피, 구모듈의 코드를 수정(과 재빌드)할 필요도 없고, 나중에 작성되는 버전들은 기존 버전들 뿐만 아니라 과거의 소비 코드에도 어떠한 영향을 미치지 않습니다.

뿐만 아니라, 링크의 OOP 코드에 비해서, FP 코드는 간결하고 가독성 측면에서도 크게 달라지는 부분도 없습니다.

특히 주목할 부분은, 링크된 글의 다음 글의

기본 인터페이스 메서드를 사용하여 mixin 형식 만들기 - C# | Microsoft Learn

맨 마지막에 있는 주의 사항에 대한 우려는 FP에는 적용되지 않습니다.

물론, 모든 C# 코드를 FP 스타일로 바꿀 수는 없습니다.
OOP 만의 장점이 특히 부각되는 부분, 예를 들면 서비스 객체의 구현에는 적절하지 않은 것 같습니다.

원글의 프로젝트는 이러한 FP 의 장점을 실험해보기 위한 것인데, 덕분에 그 장점과 단점이 더욱 선명해지는 계기가 된 것 같아 큰 도움이 되었습니다.

2개의 좋아요

개인적으로 FP에 대해서는 항상 선하다고 생각합니다.

다만 러닝커브가 있다보니 주류 프로그래밍이 되기 어려운 부분이 아쉽습니다.

FP의 장점은 불변성과 순수함수에 있는데요. 2가지를 만족시키면 이상적인 상태의 부수효과 없는 코딩이 가능합니다. 리얼월드에서는 부수효과를 IO 모나드를 통해 제어 가능하지요.

불변성과 순수함수를 지키면 모든 함수를 타입과 화살표로 표현할 수 있습니다.

버전2의 경우 SetLoyaltyThresholds로 인해 Customer.ComputeLoyaltyDiscount(c)가 순수함수를 위반합니다.
뮤테이션을 발생시키지요.

순수함수는 참조 투명성을 준수해야 합니다. 전역 변수 변경으로 인해 출력값이 달라져서는 안되요.

C#은 9.0이후 실용적측면에서 매우 진보적인 멀티 페러다임 언어가 되었는데요. C#의 FP 입문서로 다음을 추천합니다.

2개의 좋아요

추천해 주신 책은 시간이 날 때 보고는 싶은데, 448 페이지는 좀 부담스럽네요. :sweat_smile:

버전2를 순수성을 준수하도록 만든다면, 아래와 같을 것 같은데요.

using Original;
namespace Version2;
public static class Customer
{
   public static decimal ComputeLoyaltyDiscount(CustomerType customer, 
      TimeSpan age, int minimumOrders, decimal discountPercent)
   { 
       DateTime start = DateTime.Now - age;
       if ((customer.DateJoined < start) && (customer.PreviousOrders.Count() > minimumOrders))
       {
          return discountPercent;
       }
       return 0;
   }
}

좀 더 바람직한 형태가 있을까요?

2개의 좋아요

순수함수 관점에서 생각을 해보면 좋을 거 같습니다.

Version1의 경우

A → B 로 표현되는데요.
Func<ICustomer, decimal>

Version2의 경우

C → A → B 로 정의하면 될 거 같아요.
Func<ILoyaltyDiscount, Func<ICustomer, decimal>>

새로운 함수에서 기존 함수를 만드려면 C를 커링하면 됩니다.
Func<ILoyaltyDiscount, Func<ICustomer, decimal>>ILoyaltyDiscount 를 넣으면 Func<ICustomer, decimal> 가 나오게 되죠.

사용하는 측에서는 Version1으로 구현되어도 호출에 이슈가 없고 Version2로 호출하려면 ILoyaltyDiscount를 구현해서 사용하면 될 거 같군요.

// Version 1:
decimal ComputeLoyaltyDiscount(ICustomer customer)
{
    DateTime TwoYearsAgo = DateTime.Now.AddYears(-2);
    if ((customer.DateJoined < TwoYearsAgo) && (customer.PreviousOrders.Count() > 10))
    {
        return 0.10m;
    }
    return 0;
}
// Version 2:
Func<ICustomer, decimal> ComputeLoyaltyDiscountV2(ILoyaltyDiscount loyaltyDiscount) => customer =>
{
    DateTime TwoYearsAgo = DateTime.Now.AddYears(-loyaltyDiscount.MinimumYears);
    if ((customer.DateJoined < TwoYearsAgo) && (customer.PreviousOrders.Count() > loyaltyDiscount.MinimumOrders))
    {
        return loyaltyDiscount.DiscountSize;
    }
    return 0;
};

decimal ComputeLoyaltyDiscount(ICustomer customer) =>
    ComputeLoyaltyDiscountV2(new LoyaltyDiscount
    {
        DiscountSize = 0.10m,
        MinimumOrders = 10,
        MinimumYears = 2
    })(customer);

2개의 좋아요

Version 2 작성하고 보니 순수 함수가 아니군요.

DateTime.Now 는 호출할 때 마다 값이 바뀌어서 참조 투명성을 제공하지 않습니다. 따라서 impurepure function으로 부터 분리하는 작업이 필요한데요. 아래처럼 함수를 정의하면 됩니다.

DateTime → ILoyaltyDiscount → ICustomer → decimal

// Version 3:
[Pure]
Func<ILoyaltyDiscount, Func<ICustomer, decimal>> ComputeLoyaltyDiscountV3(DateTime now) => 
    loyaltyDiscount => customer =>
    {
        var yearsAgo = now.AddYears(-loyaltyDiscount.MinimumYears);
        if ((customer.DateJoined < yearsAgo) && (customer.PreviousOrders.Count() > loyaltyDiscount.MinimumOrders))
        {
            return loyaltyDiscount.DiscountSize;
        }
        return 0;
    };

Func<ICustomer, decimal> ComputeLoyaltyDiscountV2(ILoyaltyDiscount loyaltyDiscount) =>
    ComputeLoyaltyDiscountV3(DateTime.Now)(loyaltyDiscount);

decimal ComputeLoyaltyDiscount(ICustomer customer) =>
    ComputeLoyaltyDiscountV2(new LoyaltyDiscount
    {
        DiscountSize = 0.10m,
        MinimumOrders = 10,
        MinimumYears = 2
    })(customer);

ComputeLoyaltyDiscountV3 는 Pure해 졌군요.

2개의 좋아요