대메뉴 바로가기 본문 바로가기

데이터 기술 자료

데이터 기술 자료 상세보기
제목 함수형 프로그래밍 F# : 설계 관점에서 바라본 고차함수 Filter와 Map
등록일 조회수 5501
첨부파일  

함수형 프로그래밍 F#

설계 관점에서 바라본 고차함수 Filter와 Map



Filter와 Map은 함수형 프로그래밍에서 대표적인 고차 함수이다. 이번 시간에는 고차 함수의 정의와 설계 관점에서 고차 함수의 특징을 살펴본다. 그리고 Filter와 Map 함수가 어떻게 고차 함수의 개념을 활용해 구현되는지 알아본다. 고차 함수를 통한 문제 해결을 위해 어떻게(How)가 아닌 무엇(What)에 집중하는 법을 살펴보자.



고차 함수를 이용한 책임 조합

지난 시간에 짝수와 소수를 구하는 예제를 통해서 SOLID 객체지향 원칙과 고차 함수의 개념, 활용 사례를 살펴봤다. 본론으로 들어가기에 앞서 고차 함수의 개념을 다시 한 번 정리해 보자. <그림 1>과 같이 순수 함수(Pure Function)를 인자로 받을 수 있거나, 순수 함수를 결과로 내보낼 수 있는 순수 함수를 ‘고차 함수(High-Order Function 또는 고계 함수)’라고 한다. 순수 함수의 개념은 다음 기회에 자세히 다루도록 하겠다. 여기서는 단순히 수학적 함수라고 정의하고 넘어가자.

고차 함수는 인자 타입만 입력과 결과 함수에 의존하기 때문에 함수의 구체적인 구현 내용이 자연스럽게 은닉된다. 이는 객체지향에서 인터페이스와 같은 역할을 의미한다. 고차 함수의 개념을 설계 관점으로 재해석하면 <그림 2>와 같이 인자로 전달된 책임과 고차 함수의 책임을 조합한 새로운 책임을 결과로 내보낼 수도 있다. 인자 단위로 단일 책임을 입력으로 전달 받은 후에 책임들을 조합해 새로운 책임을 결과로 내보낼 수 있다.





Filter 함수의 코드를 통해 고차 함수의 특징을 좀 더 살펴보도록 하자. <그림 3>과 같이 Filter 함수는 데이터 접근 책임과 데이터 처리를 위한 판단 알고리즘 책임을 인자로 입력 받는다. Filter 함수는 데이터 추가 책임을 인자로 전달된 책임들과 조합해 새로 생성된 데이터 집합을 결과로 전달한다.





고차 함수와 SOLID 원칙

Filter 함수는 단일 책임 원칙(Single responsibility principle)인 ‘한 클래스는 하나의 책임만 가져야 한다’를 객체가 아닌 함수 단위로 준수하고 있다. Filter 함수는 입력으로 전달되는 함수를 인자의 타입에만 의존하게 된다. 인자의 타입은 입력으로 전달될 함수의 시그니처(Signature)에 해당되기 때문에 전달되는 함수의 구체적인 구현에 의존하지 않는다. 함수의 시그니처는 객체의 인터페이스에 해당되기 때문이다. 그 결과, 의존관계 역전 원칙(Dependency inversion principle)인 ‘추상화에 의존해야지, 구체화에 의존하면 안 된다’를 자연스럽게 준수하게 된다. 더욱이 의존관계 역전 원칙을 준수하기 위해 인터페이스와 같은 추가적인 구현 활동이 필요 없다.

<리스트 1>과 같이 짝수와 소수 판단 알고리즘을 Filter 함수와 분리할 수 있기 때문에 Filter을 재사용할 수 있다. 이는 개방-폐쇄 원칙(Open/closed principle)인 ‘소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다’를 준수하기 때문에 얻을 수 있는 이점이다.



<리스트 1> Filter 함수를 통한 개방-폐쇄 원칙 활용 Print("Evens:", Filter(numbers, IsEven)); Print("Primes:", Filter(numbers, IsPrime)); public bool IsEven(int number) { return (number % 2) == 0; } public bool IsPrime(int number) { for (int i = 2; i < number; i++) { if (number % i == 0) return false; } return true; }



Filter 함수

<리스트 1>에서 확인할 수 있듯이 Filter 함수의 기능을 추상화하면 <그림 4>와 같이 개념적으로 정의할 수 있다. Filer 함수는 데이터 집합과 데이터 추출 조건을 입력으로 받아서 조건에 맞는 데이터만으로 구성된 새로운 데이터 집합 결과를 내보낼 책임을 갖는 고차 함수이다.





데이터 변형을 위한 단일 책임 원칙

<리스트 2>는 데이터 집합에 개별 데이터를 변형(transform)하는 간단한 예제 프로그램이다. <리스트 2>를 실행하면 <그림 5>와 같은 날짜 형식 데이터와 정수형 데이터의 값이 변형되는 것을 확인할 수 있다. 날짜는 요일이 표현하는 형식으로 정수형 데이터는 제곱된 값으로 변형된다.



<리스트 2> 날짜와 숫자 형식 데이터 변형 using System; using System.Collections.Generic; namespace Transform { class Program { static void Main(string[] args) { DateTime[] importantDates = { new DateTime(1919, 3, 1), new DateTime(1945, 8, 15), new DateTime(1948, 4, 3), new DateTime(1980, 5, 18), new DateTime(1986, 6, 29), }; int[] numbers = { 1, 2, 3 }; Print("All:", importantDates); Print("GetPrettyDates:", GetPrettyDates(importantDates)); Print("All:", numbers); Print("Squares:", Squares(numbers)); } public static void Print< TSource>(string label, IEnumerable< TSource> source) { Console.WriteLine(label); foreach (TSource item in source) Console.WriteLine("{0}", item); Console.WriteLine(" "); } static IEnumerable< string> GetPrettyDates(IEnumerable< DateTime> dates) { List< string> result = new List< string>(); if (dates != null) { foreach (DateTime date in dates) result.Add(date.ToLongDateString()); } return result; } static IEnumerable< int> Squares(IEnumerable< int> numbers) { List< int> result = new List< int>(); if (numbers != null) { foreach (int number in numbers) result.Add(number * number); } return result; } } }





<리스트 2>에서 데이터 집합에 있는 개별 데이터를 변형시키는 GetPrettyDates 함수와 Squares 함수를 책임 단위로 보면 <그림 6>과 같다. 데이터 접근 책임은 함수 인자로 전달되기 때문에 데이터 접근 책임이 GetPrettyDates 함수와 Squares 함수에서 분리돼 있는 것을 확인할 수 있다. 그러나 데이터 변형과 관련된 기능은 data.ToLongDateString()과 number * number로 GetPrettyDates 함수와 Squares 함수 내에 각각 구현돼 있는 것을 확인할 수 있다.

데이터 변형에 대한 책임이 GetPrettyDates 함수와 Squares 함수에 있기 때문에 데이터 추가 책임과 함께 2개의 책임을 갖게 된다. 이는 단일 책임 원칙을 어기는 결과를 초래한다.



데이터 변형 책임을 GetPrettyDates 함수와 Squares 함수에서 분리해 단일 책임 원칙을 준수할 수 있도록 수정하면 데이터 접근과 데이터 변형에 대한 책임이 외부로부터 주입되기 때문에 GetPrettyDates 함수와 Squares 함수가 동일한 구현 내용을 갖게된다. 그러면 하나의 함수로 통합될 수 있을 것이다. 하나의 함수로 통합하기 전에 우선 데이터 변형 책임을 분리시켜 보도록 하자.

<리스트 3>은 단일 책임 원칙을 준수할 수 있도록 GetPretty Dates 함수와 Squares 함수에서 데이터 변형 책임을 함수 인자로 분리시킨 것이다. 데이터 변형 책임이 분리됐기 때문에 데이터 변형에 대한 책임을 갖는 GetPrettyDatesConverter 함수와 Squares Converter 함수가 추가적으로 구현된 것을 확인할 수 있다.



<리스트 3> 데이터 변형 책임 분리 using System; using System.Collections.Generic; namespace Transform { class Program { static void Main(string[] args) { DateTime[] importantDates = { new DateTime(1919, 3, 1), new DateTime(1945, 8, 15), new DateTime(1948, 4, 3), new DateTime(1980, 5, 18), new DateTime(1986, 6, 29), }; int[] numbers = { 1, 2, 3 }; Print("All:", importantDates); Print("GetPrettyDates:", GetPrettyDates(importantDates, GetPrettyDatesConverter)); Print("All:", numbers); Print("Squares:", Squares(numbers, SquaresConverter)); } public static void Print< TSource>(string label, IEnumerable< TSource> source) { Console.WriteLine(label); foreach (TSource item in source) Console.WriteLine("{0}", item); Console.WriteLine(" "); } static string GetPrettyDatesConverter(DateTime date) { return date.ToLongDateString(); } static int SquaresConverter(int number) { return number * number; } static IEnumerable< string> GetPrettyDates(IEnumerable< DateTime> dates, Func< DateTime, string> converter) { List< string> result = new List< string>(); if (dates != null) { foreach (DateTime date in dates) result.Add(converter(date)); } return result; } static IEnumerable< int> Squares(IEnumerable< int> numbers, Func< int, int> converter) { List< int> result = new List< int>(); if (numbers != null) { foreach (int number in numbers) result.Add(converter(number)); } return result; } } }



<리스트 3>의 GetPrettyDates 함수와 Squares 함수의 책임을 다시 정리해보면 <그림 7>과 같이 데이터 변형 책임이 함수의 인자로 분리돼 함수 외부에서 해당 책임을 주입할 수 있도록 수정된 것을 확인할 수 있다.



데이터 변형 책임이 외부로 분리되면서 GetPrettyDates 함수와 Squares 함수는 처리하는 데이터 타입만 다를 뿐 그 외는 100% 동일한 구현을 갖는 함수임을 확인할 수 있다. 서로 다른 데이터 타입은 제네릭(Generic) 함수로 대체할 수 있기 때문에 GetPrettyDates 함수와 Squares 함수는 단일 함수로 통합될 수 있다. 단일 함수로 통합될 함수 이름을 Map으로 정의해 수정하면 <리스트 4>와 같다.



<리스트 4> Map 함수 using System; using System.Collections.Generic; namespace Map_HighOrderFunction { class Program { static void Main(string[] args) { DateTime[] importantDates = { new DateTime(1919, 3, 1), new DateTime(1945, 8, 15), new DateTime(1948, 4, 3), new DateTime(1980, 5, 18), new DateTime(1986, 6, 29), }; int[] numbers = { 1, 2, 3 }; Print("All:", importantDates); Print("GetPrettyDate:", Map< DateTime, string>(importantDates, GetPrettyDate)); Print("All:", numbers); Print("Squares:", Map< int, int>(numbers, Squares)); } public static void Print< TSource>(string label, IEnumerable< TSource> source) { Console.WriteLine(label); foreach (TSource item in source) Console.WriteLine("{0}", item); Console.WriteLine(" "); } static IEnumerable< TResult> Map< TSource, TResult>(IEnumerable < TSource> source, Func< TSource, TResult> converter) { List< TResult> result = new List< TResult>(); if (source != null) { foreach (TSource item in source) result.Add(converter(item)); } return result; } static string GetPrettyDate(DateTime date) { return date.ToLongDateString(); } static int Squares(int number) { return number * number; } } }



<리스트 4>에 구현된 Map 함수의 세부 내용을 좀 더 살펴보면 <그림 8>과 같이 데이터 접근과 데이터 변형에 대한 책임이 외부에서 주입될 수 있도록 해당 책임이 분리돼 있음을 알 수 있다. 그리고 Map은 입력으로 전달된 책임들과 데이터 추가 책임을 조합하게 돼 단일 책임 원칙을 준수한다. 또 데이터 접근과 데이터 변형에 대한 구체적인 구현이 아닌 해당 책임을 갖는 함수의 시그니처에만 의존하기 때문에 의존관계 역전 원칙을 준수하게 된다.



단일 책임 원칙과 의존관계 역전 원칙을 통한 개방-폐쇄 원칙을 준수하고 있기 때문에 날짜(GetPrettyDate)와 정수(Squares) 데이터 변형에 Map 함수를 재사용할 수 있었다.



Map 함수

지금까지 데이터 변형과 관련해 구현한 Map 함수를 개념적으로 정의하면 <그림 9>와 같다. Map 함수는 인자로 전달된 변형 책임 함수를 데이터 집합의 모든 요소에 적용(apply)하고, 그 결과를 내보내는 책임을 갖는 고차 함수이다.





Filter와 Map 함수 활용

Filter과 Map 함수를 활용한 간단한 예제를 통해 ‘문제를 해결하기 위해서 어떻게 할 것인가’가 아닌 ‘문제 해(解)의 특성을 설명하기 위해서 무엇을 할 것인가’의 관점에서 함수 활용를 살펴보자. <리스트 5>는 데이터 집합에서 Filter 함수를 이용해 짝수 데이터만 추출한 후, Map 함수를 이용해 짝수 데이터의 제곱을 구하는 예제 프로그램이다. <리스트 5>의 구현 내용을 살펴보면 입력 데이터 집합으로부터 짝수의 제곱 데이터 집합을 얻기 위해 짝수(IsEven 함수)와 제곱(Square 함수) 구현에만 집중하고 있는 것을 확인할 수 있다.



<리스트 5> Filter와 Map 함수 조합 using System; using System.Collections.Generic; namespace FilterMap_HOF { class Program { static void Main(string[] args) { int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; //, 11, 12, 13, 14, 15 }; numbers.Print("All:"); numbers .Filter(IsEven) .Print("Evens:") .Map(Square) .Print("Squares:"); } static bool IsEven(int value) { return (value % 2) == 0; } static int Square(int value) { return value * value; } } static class Extension { public static IEnumerable< TSource> Filter< TSource> (this IEnumerable< TSource> source, Func< TSource, bool> predicate) { List< TSource> result = new List< TSource>(); if (source != null) { foreach (TSource item in source) { if (predicate(item)) result.Add(item); } } return result; } public static IEnumerable< TResult> Map< TSource, TResult> (this IEnumerable< TSource> source, Func< TSource, TResult> converter) { List< TResult> result = new List< TResult>(); if (source != null) { foreach (TSource item in source) result.Add(converter(item)); } return result; } public static IEnumerable< TSource> Print< TSource>(this IEnumerable< TSource> source, string label) { Console.WriteLine(label); foreach (TSource item in source) Console.Write(" {0}", item); Console.WriteLine(" "); return source; } } }



<리스트 5>에서 구현된 내용을 시각화하면 <그림 10>과 같이 numbers 입력으로 시작해서 Filter 함수에 주입된 데이터 추출 조건을 갖는 IsEven 함수를 통해 데이터를 선별한다. 그리고 Map 함수에 주입된 Square 함수를 선별된 모든 데이터에 적용함으로써 짝수 제곱된 결과 값을 얻는다.



<그림 11>은 <리스트 5>를 수행한 결과이다. 이를 통해 IsEven 함수로 짝수 데이터만 출력되는 것을 확인할 수 있다. 그리고 Square 함수를 활용해 모든 짝수 데이터에 대해 제곱된 결과도 확인할 수 있다.



C#을 통해 구현된 <리스트 5>를 F# 함수형 언어로 구현하면 <리스트 6>과 같다. C#과 Java는 객체지향으로부터 함수형을 제공하고 있다. 그러나 F#, Scale와 같은 함수형 언어는 함수형으로부터 객체지향을 제공한다. 함수형은 컴퓨터의 문제 해결을 위해 컴퓨터를 추상화하는 것이 아닌 수학적 함수를 추상화하며, 선언형(Declarative)에 뿌리를 두고 있기 때문에 객체지향(명령형)보다 더 높은 추상화를 제공한다. 이를 통해 문제의 본질에 더 집중함으로써 문제를 해결할 수 있다. <리스트 6>을 실행하면 <그림 12>와 같이 정상적으로 목표했던 결과 값이 출력됨을 확인할 수 있다.



<리스트 6> F#을 이용한 Filter와 Map 함수 조합 [< EntryPoint>] let main argv = let IsEven x = x % 2 = 0 let Square x = x * x [1 .. 10] |> List.filter(IsEven) |> List.map(Square) |> List.iter(printfn "%d") 0





Filter와 Map 함수 개념을 살펴보기 위해 사용한 C# 구현 코드는 .NET 프레임워크에 제공되는 LINQ와 F# 실제 구현 내용과는 다르다. Filter와 Map 함수 구현과 관련된 좀 더 자세한 내용을 살펴보고 싶다면 GitHub에 공개된 CoreFx와 F# 코드를 살펴보면 된다.



출처 : 마이크로소프트웨어 6월호

제공 : 데이터 전문가 지식포털 DBguide.net