[C#] 함수형 프로그래밍 입문

Running boy·2023년 8월 31일
0

컴퓨터 공학

목록 보기
36/36

위키백과에서는 함수형 프로그래밍을 아래와 같이 정의한다.

자료 처리를 수학적 함수의 계산으로 취급하고 상태와 가변 데이터를 멀리하는 프로그래밍 패러다임의 하나이다.

절차지향, 객체지향 프로그래밍과 같은 명령형 프로그래밍어떻게(How) 계산하는지에 초점을 맞췄다.
예를 들어 배열에서 2 이상의 요소의 개수를 구하는 코드를 작성한다고 해보자.

int[] arr = new int[] { 1, 2, 3, 4, 5 };

int count = 0;
for (int i = 0; i < arr.Length; i++)
{
    if (arr[i] >= 2)
        count++;
}
        
Console.WriteLine(count);

반대로 함수형 프로그래밍과 같은 선언형 프로그래밍무엇을(What) 계산하는지에 초점을 맞춘다.
같은 예시로 코드를 작성해본다.

int[] arr = new int[] { 1, 2, 3, 4, 5 };

int count = arr.Count(x => x >= 2);

Console.WriteLine(count);

뭔가 굉장히 낯선 형태의 코드지만 대충 봐도 저 함수가 어떤 기능을 하는지 명확히 보인다.

  1. Count라는 함수는 어떤 배열이든 그 요소의 개수를 반환할 것이다.
  2. Count의 인자로 넘겨진 또다른 함수 'x => x >= 2'는 배열을 필터링하는 조건일 것이다.

이게 바로 선언형 프로그래밍의 강점 중 하나이다.
명령형 프로그래밍은 과정의 해석이 필요한 반면에 선언형 프로그래밍은 무엇을 목적으로 하는지가 명확하다.

근데 우리가 위 코드를 봤을 때 어떻게 그 기능을 확신할 수 있었을까?
그것은 함수형 프로그래밍이 순수 함수로 이루어졌기 때문이다.
Count는 동일한 배열, 동일한 조건에 대해서는 항상 같은 결과를 반환할 것이며, 조건의 변경에 따라 함수가 독립적으로 동작할 것이며, 기존의 요소에 영향을 주지 않는다.
이게 순수 함수의 특징이자 장점이다.


정리하면 함수형 프로그래밍은 순수 함수와 불변성을 기반으로 한 선언형 프로그래밍 스타일을 추구한다.
외부로부터 독립적인 순수 함수를 만들어서 사용하면 함수는 수학적 의미의 함수로써 사용될 수 있다.
예를 들어 Max(int a, int b)는 외부의 어떤 조건에도 상관없이 a와 b 중 큰 값을 반환할 것이다.
이러한 프로그래밍 스타일은 코드가 난잡해지는 것을 방지하여 유지보수성을 높이는데 도움이 된다.

C#으로 함수형 프로그래밍 입문

C#은 객체지향 언어이고 객체지향 프로그래밍에 특화된 언어이다.
하지만 그렇다고 함수형 프로그래밍이 불가능한 것은 아니다.

자고로 명령형, 함수형 프로그래밍 등은 프로그래밍 '패러다임'이다.
그 말은 즉 그저 코드를 표현하는 표현 방식에 지나지 않는다.
우리가 코드의 표현 방식을 함수형에 맞춘다면 그것이 곧 함수형 프로그래밍이다.

C#에서 함수형 프로그래밍에 거의 필수적인 개념이 확장 메서드람다 메서드이다.
사실 이 둘은 이미 위의 예제에서 사용됐다.
'Count' 메서드가 확장 메서드에 해당하고 'x => x >= 2'가 람다 메서드에 해당한다.
이 두 개념에 대해 이해하고 함수형 프로그래밍 패러다임을 반영하여 설계된 BCL의 LINQ 라이브러리에 대해 간단히 정리해보자.

확장 메서드(Extension Method)

클래스를 상속받지 않고 확장할 수 있는 방법으로 C# 3.0에서 추가된 개념이다.
상속으로 확장할 경우 기존의 코드를 전부 수정해야 되는 불편함이 있고 또 sealed 클래스는 상속이 불가능하기 때문에 확장 메서드가 자주 사용된다.

확장 메서드는 몇가지 규칙만 지키면 어렵지 않게 정의할 수 있다.

  1. static 클래스에서 정의해야 한다.
  2. static으로 정의해야 한다.
  3. 확장하려는 타입의 매개변수를 this 예약어와 함께 명시해야 한다.
static class TestExtension
{
    public static int? ToInt32(this string str)
    {
        if (int.TryParse(str, out var result))
        {
            return result;
        }
        return null;
    }
}

class Program
{
    static void Main(string[] args)
    {
        string s = "1234";

        Console.WriteLine(s.ToInt32());
    }
}

여기서 매개변수의 this 예약어 덕분에 확장 타입의 인스턴스로부터 직접 호출할 수 있음을 알 수 있다.
this 예약어의 이러한 활용은 확장 메서드에서만 가능하다.

추가로 비주얼 스튜디오 인텔리센스에서 확인하면 확장 메서드는 아래 화살표 아이콘으로 표시된다.

지연된 평가(Lazy Evaluation)

리스트에서 특정 조건을 만족하는 부분 리스트를 구하는 방법에는 여러가지가 있지만 해당 기능을 제공하는 메서드 두 개가 있다.

// List 타입의 멤버 메서드
public List<T> FindAll(Predicate<T> match);

// Enumerable 타입의 확장 메서드
public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate);

두 메서드는 완벽하게 동일한 기능을 한다.
반환값의 차이를 제외하면 특별히 다른건 보이지 않는다. (Predicate<T>와 Func<TSource, bool> 차이는 아래에서 후술하겠지만 동일하게 취급한다.)
그렇다면 이미 FindAll이라는 메서드가 있는데 왜 Where이라는 확장 메서드가 추가된 것일까?

열거자의 특징을 생각해보자.
Enumerable의 내부에 실질적으로 값이 저장된 Enumerator가 있으며 이는 GetEnumerator 메서드로 가져올 수 있다.
이후 MoveNext 메서드의 반환값이 true인 경우 index를 증가시키고 Current값을 가져온다.
이 과정에 Where 메서드의 인자로 넘겨진 조건이 추가된다고 해보자.
Current값을 yield return 하기 전에 if문으로 조건만 추가하면 된다.

요점은 Where 메서드가 반환하는 Enumerable은 아직 연산이 진행되지 않았다는 것이다.
그저 연산 조건이 추가된 새로운 Enumerable이 반환된 것이며 실질적인 연산은 반복문을 통해 값을 얻을 때 진행될 것이다.

반면에 FindAll 메서드는 어떠한가?
FindAll 메서드는 내부에서 직접 반복문을 돌며 match 조건에 맞는 값을 추려내어 새로운 리스트에 담아 반환한다.
이미 연산이 끝난 결과물을 반환하는 것이다.

이렇듯 Enumerable을 반환하여 연산을 지연하는 것을 지연된 평가라고 한다.


대부분의 경우 지연된 평가의 중요성은 돋보이지 않는다.
하지만 CPU의 부하가 큰 작업을 진행할 때 지연된 평가의 성능이 돋보이게 된다.

1조 이하의 모든 소수를 반환하는 메서드를 호출했다고 가정해보자. (메모리의 용량은 무한하다고 가정하자.)
FindAll 메서드로 모든 소수를 찾는다면 아마 한동안 프로그램이 멈출 것이다.
하지만 Where 메서드는 연산을 하지 않기 때문에 아무런 부하가 없다.

한걸음 더 나아가 그 소수의 리스트에서 500번째 요소까지를 출력한다고 해보자.
FindAll 메서드로 우선 1조 이하의 모든 소수를 구한 리스트를 반환하고 또 그 리스트 안에서 500번째까지 출력을 진행해야 한다.
하지만 Where 메서드는 Enumerator를 순차적으로 돌며 500번째 값을 반환하고 멈추면 그만이다.

주의할 점

지연된 평가는 마치 참조와 같다.
어떤 Enumerable을 통해 생겨난 새로운 Enumerable은 기존 Enumerable을 참조하여 평가 대상으로 남겨놓는다.
그렇기 때문에 새로운 Enumerable이 평가되기 전에 기존 Enumerable에 변화가 생긴다면 영향을 받게 된다.

List<int> list = new List<int>() { 1, 2, 3, 4, 5 };

var newList = list.Where(x => x > 3);
list.Clear();
foreach (var x in newList)
{
    Console.WriteLine(x);
}

newList는 원래 4, 5를 출력하는 것이 정상이지만 list가 clear 되고 평가식이 수정되면서 아무것도 출력하지 않는다.
list가 clear 되기 전에 ToList 등으로 평가를 진행하면 정상적인 값을 갖게 된다.

람다 메서드(Lambda Method)

람다 대수의 형식을 C#에서 구현한 문법으로 확장 메서드와 함께 C# 3.0에서 추가되었다.
코드 블럭으로 정의되는 람다 구문과 약식으로 표현되는 람다 식으로 분류된다.

람다 구문(Lambda Syntax)

람다 구문은 사실 익명 메서드를 더 단순하게 표기한 것에 지나지 않는다.

class Program
{
    delegate int Sum(int a, int b);

    static void Main(string[] args)
    {
        Sum sum1 = delegate (int a, int b)
        {
            return a + b;
        };

        Console.WriteLine(sum1(1, 2));
        
        Sum sum2 = (a, b) =>
        {
            return a + b;
        };

        Console.WriteLine(sum2(1, 2));
    }
}

람다 구문은 주로 여러 줄의 코드로 구성될 경우 사용된다. (세미콜론(;)이 여러개인 경우)

람다 식(Lambda Expression)

람다 구문의 내부 코드가 식으로 평가될 수 있는 경우, 즉 한 줄의 코드로 구성될 경우 코드 블럭과 return을 생략할 수 있다.

Sum sum3 = (a, b) => a + b;

전용 델리게이트

람다 메서드 자체가 익명 메서드이기 때문에 델리게이트에 담길 수 있음이 확인됐다.
하지만 매번 람다 메서드를 사용하려고 델리게이트를 선언하는건 여간 귀찮은 일이 아니다.
그래서 마이크로소프트는 BCL에 미리 전용 델리게이트 타입을 제네릭으로 일반화해서 구현해놨다.

Action

public delegate void Action();
public delegate void Action<in T>(T obj);
public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2);
.
.
.
public delegate void Action<in T1, in T2, ... , in T16>(T1 arg1, T2 arg2, ... , T16 arg16);

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 T16, out TResult>(T1 arg1, T2 arg2, ... , T16 arg16);

Predicate

public delegate bool Predicate<in T>(T obj);

Action과 Func의 차이점은 반환값의 유무이다.
Predicate는 값의 분기가 있는 등 특수한 경우에 사용되는 델리게이트로 사실상 Func<T, bool>과 동일하다.

LINQ(Language-INtegrated Query)

LINQ는 이름에서 알 수 있듯이 기존 문법 체계에 쿼리 문법을 통합한 새로운 문법 체계이다.

SQL 쿼리 문법과 LINQ 쿼리 문법은 상당히 유사하다.

// SQL query
SELECT element FROM list
// LINQ query
from element in list
select element

또한 LINQ는 기존 IEnumerable<T>의 확장 메서드를 다수 지원한다. (정확히는, 쿼리 문법을 지원하기 위해 확장 메서드가 추가되었다.)
그래서 쿼리 문법을 몰라도 그에 대응되는 확장 메서드를 사용하여 아래와 같이 쿼리문을 작성할 수 있다.

// Extension Method
list.Select(element => element)

그리고 이를 또 명령형 프로그래밍 패러다임으로 풀어쓰면 다음과 같다.

// Imperative Programming
IEnumerable<ElementType> SelectFunc(List<ElementType> list)
{
	foreach (var element in list)
    {
    	yield return element;
    }
}

내가 LINQ를 굳이 함수형 프로그래밍 포스트에 정리하는 이유도 LINQ의 확장 메서드 때문이다.
확장 메서드를 사용해서 쿼리문을 작성하면 '데이터 열거'에 한해서 강력한 함수형 프로그래밍 패러다임이 완성된다.

LINQ는 함수형 프로그래밍을 기반으로 설계됐다.
그렇기에 각 확장 메서드는 순수 함수로 이루어져 있으며 원본 컬렉션의 불변성이 보장된다.
게다가 반환형이 IEnumerable인 경우 지연된 평가로 성능 향상을 노려볼 수도 있다.
단, 그와 반대로 IEnumerable을 반환하지 않는 Max, Min 등은 즉시 평가가 이루어지니 참고하자.

표준 쿼리 연산자(Standard Query Operators)

LINQ에서 정의하는 IEnumerable<T>의 확장 메서드를 가리키는 말이다.

LINQ에서 지원하는 모든 쿼리 문법은 이에 대응되는 표준 쿼리 연산자가 존재한다.
하지만 반대로, 모든 표준 쿼리 연산자에 대응되는 쿼리 문법이 존재하는 것은 아니다.
예를 들어 컬렉션의 요소의 합을 반환하는 Sum 메서드의 경우 쿼리 문법으로 표현이 불가능하다.
따라서 쿼리 문법의 작성법은 이해하되 가급적이면 표준 쿼리 연산자를 사용하는 것이 좋을 것이다. (본인이 SQL 문법이 익숙하다면 선택. 하지만 읽는 사람 입장도 생각하자...)

모든 표준 쿼리 연산자를 정리해둔 블로그를 하단에 링크했다.
본 포스트에서는 비교적 자주 쓰이는 표준 연산자만 정리하겠다.

Select

Select 자체는 요소를 반환하기 때문에 큰 의미가 없어보인다.
하지만 Select는 형변환을 비롯한 데이터 가공과 함께 할 때 강력해진다.

List<int> list = new List<int>() { 1, 2, 3, 4, 5 };
        
var newList = list.Select(x => (float)x).ToList();
newList.ForEach(x => Console.Write((x is float) + " "));
        
Console.WriteLine();
        
var newList2 = list.Select(x => new { current = x, next = x + 1 }).ToList();
newList2.ForEach(x => Console.WriteLine($"current: {x.current}, next: {x.next}"));
True True True True True 
current: 1, next: 2
current: 2, next: 3
current: 3, next: 4
current: 4, next: 5
current: 5, next: 6

첫번째는 int 타입의 요소를 float 타입으로 형변환하여 새로운 리스트를 반환했다.
두번째는 익명 타입을 정의하여 익명 타입의 리스트를 반환했다.

표준 연산자를 쿼리 문법으로 바꾸면 아래와 같다.

var newList = (from elem in list
				select (float)elem)
            	.ToList();
                       
var newList2 = (from elem in list
				select new { current = elem, next = elem + 1 })
            	.ToList();

Where

쿼리에 조건을 부여하여 필터링한다.

List<int> list = new List<int>() { 1, 2, 3, 4, 5 };

var newList = list.Where(x => x > 3).ToList();
newList.ForEach(x => Console.Write(x + " "));
4 5 

표준 연산자를 쿼리 문법으로 바꾸면 아래와 같다.

var newList = (from elem in list
                where elem > 3
                select elem)
                .ToList();

반환 타입이 bool인 경우 where절에 사용될 수 있다.

OrderBy / OrderByDescending

컬렉션의 요소를 오름차순/내림차순으로 정렬한다.

List<int> list = new List<int>() { 2, 1, 5, 3, 4 };

var ascendingList = list.OrderBy(x => x).ToList();
ascendingList.ForEach(x => Console.Write(x + " "));

Console.WriteLine();

var descendingList = list.OrderByDescending(x => x).ToList();
descendingList.ForEach(x => Console.Write(x + " "));
1 2 3 4 5 
5 4 3 2 1 

표준 연산자를 쿼리 문법으로 바꾸면 아래와 같다.

var ascendingList = (from elem in list
                        orderby elem
                        select elem)
                        .ToList();

var descendingList = (from elem in list
                        orderby elem descending
                        select elem)
                        .ToList();

IComparable 인터페이스를 구현한 타입인 경우 orderby절에 사용될 수 있다.

GroupBy

컬렉션을 여러 그룹으로 분류한다.

List<int> list = new List<int>() { 2, 1, 5, 3, 4 };

var groups = list.GroupBy(x => x > 3).ToList();
groups.ForEach(group =>
{
    Console.WriteLine($"Bigger than 3: {group.Key}");
    group.ToList().ForEach(x => Console.Write(x + " "));
    Console.WriteLine();
});
Bigger than 3: False
2 1 3 
Bigger than 3: True
5 4 

표준 연산자를 쿼리 문법으로 바꾸면 아래와 같다.

var groups = (from elem in list
                group elem by elem > 3)
                .ToList();

특이하게 group by절은 select절과 함께 사용될 수 없다.
그 이유는 group의 기능 자체가 select를 포함하기 때문이다.
따라서 select에서 가능했던 형변환과 데이터 가공 역시 group by에서 가능하다.

예시에서는 bool값을 기준으로 그룹을 분류했지만 분류 기준은 정하기 나름이다.

Join

두 그룹에서 조건을 만족하는 요소를 묶어서 반환한다. (굳이 안묶어도 되긴 한다.)
두 그룹을 비교할 수 있다는 것에 의미를 둔다.

List<int> list = new List<int>() { 1, 2, 3, 4, 5 };
List<int> list2 = new List<int>() { 3, 4, 5, 6, 7 };

var newList = list.Join(list2, x => x, y => y, (x, y) => new { x, y }).ToList();
newList.ForEach(x => Console.WriteLine(x));
{ x = 3, y = 3 }
{ x = 4, y = 4 }
{ x = 5, y = 5 }

Join 메서드의 인자를 보면 뭔가 람다 식이 많다.
첫번째 인자부터 순서대로 inner, outerKeySelector, innerKeySelector, resultSelector에 해당한다.

표준 연산자를 쿼리 문법으로 바꾸면 아래와 같다.

var newList = (from x in list
                join y in list2 on x equals y
                select new { x, y })
                .ToList();

참고 자료
시작하세요! C# 10 프로그래밍 - 정성태
[프로그래밍] 함수형 프로그래밍(Functional Programming) 이란?
[C#] LINQ 표준 연산자

profile
Runner's high를 목표로

0개의 댓글