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

데이터 기술 자료

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

함수형 프로그래밍 F#

설계 관점에서 바라본 고차 함수 Reduce



리듀스(Reduce)는 함수형 프로그래밍에서 ‘폴드(fold)’라는 고차 함수의 계열 중 하나다. 폴드는 리듀스 외에도 accumulate, compress 혹은 inject 등이 있다. 이번 시간에는 설계 관점에서 리듀스의 특징을 살펴볼 것이다. 또 6월호에서 다뤘던 함수형 프로그래밍의 대표적인 고차 함수 Filter와 Map를 활용한 예제를 통해 함수형 프로그래밍 패러다임을 살펴본다.



단일 결과 값을 위한 단일 책임 원칙

리듀스를 살펴보기 전에 간단한 예제를 통해 리듀스의 개념을 알아보자. <리스트 1>은 정수가 1에서 10까지 있는 배열에서 합과 최대 값을 구하는 예제 프로그램이다.



<리스트 1> 합과 최대 값 구하기 using System; using System.Collections.Generic; namespace _01_IndividualResponsibility { class Program { static void Main(string[] args) { int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; Console.WriteLine("Sum: {0}", Sum(numbers)); Console.WriteLine("Max: {0}", Max(numbers)); } static int Sum(IEnumerable< int> numbers) { int result = 0; if (numbers != null) { foreach (int number in numbers) { result = result + number; } } return result; } static int Max(IEnumerable< int> numbers) { int result = int.MinValue; if (numbers != null) { foreach (int number in numbers) { if (number > result) result = number; } } return result; } } }



<리스트 1>을 컴파일해 실행하면 <그림 1>과 같이 정수 배열의 합은 55, 최대 값은 10이 출력되는 것을 확인할 수 있다. <리스트 1>에서 구현한 Sum과 Max 함수의 내부 연산 과정을 도식화하면 <그림 2>, <그림 3>과 같다.







<그림 2>, <그림 3>을 통해 Sum과 Max 함수 연산 과정의 공통점을 찾을 수 있다. 첫째는 +와 < 연산 결과가 다음 연산에 사용되고 있다는 점이다. Sum 함수에서 ‘0 + 1’의 결과 값 1이 다음 데이터 2와 연산할 때 ‘1 + 2’와 같이 사용된다. Max도 Sum과 같이 ‘0 < 1’의 결과 값 1이 다음 데이터 2와 연산할 때 ‘1 < 2’와 같이 사용된다. 둘째는 +와 < 연산 모두 결합 법칙을 준수한다는 점이다. +와 < 연산 모두 연산 순서가 바뀌어도 결과 값이 항상 같다. 결합 법칙으로 인해 ‘(1 + 2) + 3’과 ‘1 + (2 + 3)’이 연산 순서는 다르지만 결과 값은 같게 된다. < 연산도 역시 연산 순서에 상관없이 항상 결과 값이 같다. 셋째는 단일 결과 값을 만든다는 점이다. Sum 함수에서는 55를 Max 함수에서는 10의 단일 결과 값을 만든다.

이러한 공통적인 연산 특징으로 인해 <리스트 1>은 foreach를 통해 순차적으로 데이터를 순회하면서 +와 < 연산을 수행한다. 그러나 멀티 코어와 매니 코어 환경을 고려해 해당 연산을 병렬로 수행할 수도 있다. +와 < 연산은 모두 결합 법칙을 준수하기 때문에 연산 순서에 의존하지 않아 가능한 일이다. Sum과 Max의 결합 법칙을 활용한 병렬 작업을 시각화하면 <그림 4>, <그림 5>와 같이 수행할 수도 있다.





<리스트 1>의 Sum과 Max 함수를 설계 관점에서 다시 살펴보자. Sum과 Max 함수의 책임을 기준으로 구현 내용을 살펴보면 <그림 6>과 같이 ‘데이터 접근’에 대한 책임은 외부로 분리돼 있지만, Sum과 Max 함수 모두 동일하게 ‘결합 연산(combining operation)’과 ‘결과 누적’이라는 2개의 책임을 가진다. “한 클래스는 하나의 책임만 가져야 한다”는 단일 책임 원칙(Single responsibility principle)에 따라 함수 단위로 바라보면 Sum과 Max는 단일 책임 원칙을 준수하지 못한다.



‘책임2’에 해당하는 ‘결합 연산’ 책임을 Sum과 Max 함수로부터 분리해 보자. 결합 연산을 분리하기 위해서는 Sum에서 + 연산을 수행하기 위한 초기 값 0을, Max에서도 역시 < 연산을 수행하기 위한 초기 값 Int.MinValue를 외부로부터 전달받아야 한다. 이러한 조건들을 만족하기 위해 Sum 함수는 “int Sum (IEnumerable numbers)”에서 “int Sum(IEnumerable numbers, int initValue, Func accumulator)”로 변경한다. 초기 값은 initValue에, 결합 연산은 accumulator에 전달하게 된다. Max 함수도 Sum 함수와 같이 “int Max(IEnumerable numbers)”에서 “int Max(IEnumerable numbers, int initValue, Func accumulator)”로 변경한다. <리스트 2>는 Sum과 Max 함수에서 결합 연산 책임을 분리한 전체 구현 내용이다.



<리스트 2> 결합 연산 책임 분리를 통한 합과 최대 값 구하기 using System; using System.Collections.Generic; namespace _02_SeparateResponsibility { class Program { static void Main(string[] args) { int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; Console.WriteLine("Sum: {0}", Sum(numbers, 0, SumAccumulator)); Console.WriteLine("Max: {0}", Max(numbers, int.MinValue, MaxAccumulator)); } static int SumAccumulator(int x, int y) { return x + y; } static int MaxAccumulator(int x, int y) { return x > y ? x : y; } static int Sum(IEnumerable< int> numbers, int initValue, Func< int, int, int> accumulator) { int result = initValue; if (numbers != null) { foreach (int number in numbers) result = accumulator(number, result); } return result; } static int Max(IEnumerable< int> numbers, int initValue, Func< int, int, int> accumulator) { int result = initValue; if (numbers != null) { foreach (int number in numbers) result = accumulator(number, result); } return result; } } }



<리스트 2>를 컴파일해 실행하면 <리스트 1>과 같은 결과를 얻는다. 결합 연산 책임이 분리된 Sum과 Max 함수는 함수 이름을 제외하고 모두 동일하게 구현된 것임을 확인할 수 있다. 이번에는 Sum과 Max 함수를 통합하는 내용이다. 해당 함수 이름을 Reduce로 하고 타입 의존성을 최소화하기 위해 템플릿으로 정리한다. <리스트 3>은 이에 관한 내용이다.



<리스트 3> Reduce 함수로 합과 최대 값 구하기 using System; using System.Collections.Generic; namespace _03_Reduce { class Program { static void Main(string[] args) { int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; Console.WriteLine("Sum: {0}", Reduce(numbers, 0, SumAccumulator)); Console.WriteLine("Max: {0}", Reduce(numbers, int.MinValue, MaxAccumulator)); } static int SumAccumulator(int x, int y) { return x + y; } static int MaxAccumulator(int x, int y) { return x > y ? x : y; } static TResult Reduce< TSource, TResult> (IEnumerable< TSource> source, TResult initValue, Func< TSource, TResult, TResult> accumulator) { TResult result = initValue; if (source != null) { foreach (TSource item in source) result = accumulator(item, result); } return result; } } }



결합 연산 책임을 분리하고 Reduce 함수를 책임 기준으로 확인해 보자. 그러면 <그림 7>과 같이 Reduce 함수는 입력으로 전달된 책임(데이터 접근, 결합 연산)들과 조합해 ‘결과 누적’ 단일 책임만을 갖게 되므로 단일 책임을 준수하게 된다.





Reduce 함수

앞에서 살펴본 것과 같이 Reduce 함수의 기능을 추상화하면 <그림 8>과 같이 개념적으로 정의할 수 있다. Reduce 함수는 데이터 집합과 결합 연산을 입력받아 연산 결과가 누적된 단일 결과 값을 내보낼 책임을 갖는 고차 함수이다.





고차 함수 활용

지난 시간에 이미 살펴본 Filter와 Map 함수를 Reduce 함수와 함께 활용하는 간단한 예제를 통해 함수형 프로그래밍 패러다임을 살펴보자. 1에서 10까지 정수 배열에서 짝수 값만을 대상으로 제곱 값의 합을 구하려고 한다. 기존 명령형 프로그래밍 패러다임으로 해당 요구사항을 구현하면 ‘문제를 해결하기 위해 어떻게(How) 할 것인가’에 무게를 둔다. 반면에 함수형 프로그래밍 패러다임은 ‘문제 해(解)의 특성을 설명하기 위해 무엇(What)을 할 것인가’를 표현하는 데 무게를 둔다.

앞에 제시된 요구사항을 다시 문제 해의 특성으로 식별해 코드로 정리하면 <표 1>과 같이 자연어 요구사항이 코드와 1:1 관계를 이룬다. 참고로 함수형 프로그래밍 패러다임이 항상 자연어와 1:1 관계를 갖는 것은 아니다. 그러나 기존 명령형 언어보다는 상당히 높은 수준의 추상화를 제공하기 때문에 자연어에 더 가깝게 코드를 구현할 수 있다. <표 1>을 통해 추출된 고차 함수들을 사용해 전체 코드를 구현하면 <리스트 4>와 같다.





<리스트 4> 짝수의 제곱 합 구하기 using System; using System.Collections.Generic; namespace _04_FilterMapReduce { class Program { static void Main(string[] args) { int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; int sum = numbers .Print("All:") .Filter(IsEven) .Print("Evens:") .Map(Square) .Print("Squares:") .Reduce(0, Add); Console.WriteLine("Sum: {0}", sum); } static bool IsEven(int value) { return (value % 2) == 0; } static int Square(int value) { return value * value; } static int Add(int x, int y) { return x + y; } } 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 TResult Reduce< TSource, TResult> (this IEnumerable< TSource> source, TResult initValue, Func< TSource, TResult, TResult> accumulator) { TResult result = initValue; if (source != null) { foreach (TSource item in source) result = accumulator(item, result); } 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; } } }



<리스트 4>의 내부 연산 과정을 시각화하면 <그림 9>와 같다. 그리고 프로그래밍 구현 내용은 방향 그래프(directed graph)로도 표현할 수 있다.



<리스트 4>의 실행 결과는 <그림 10>과 같이 정상적으로 Filter 함수를 통해 짝수만 추출되고, 추출된 값으로부터 Map 함수를 통해 제곱한 결과 값을 확인할 수 있다. 마지막으로 220이라는 합 결과를 얻었다.



<리스트 4>에서 구현한 Filter와 Map 함수를 좀 더 추상화하면 Filter와 Map 함수 모두 새로운 단일 결과 값(IEnumerable )을 만들어 낸다. 이는 Reduce의 특징이기 때문에 Filter와 Map 함수의 구현을 Reduce로 통일할 수 있다. <리스트 5>는 Filter와 Map 함수를 Reduce로 통일하는 방법이다.



<리스트 5> Reduce로 Filter와 Map 함수 구현하기 static class Extension { public static IEnumerable< TSource> Filter< TSource> (this IEnumerable< TSource> source, Func< TSource, bool> predicate) { return Reduce(source, new List< TSource>(), (TSource item, List< TSource> result) => { if (predicate(item)) result.Add(item); return result; }); } public static IEnumerable< TResult> Map< TSource, TResult> (this IEnumerable< TSource> source, Func< TSource, TResult> converter) { return Reduce(source, new List< TResult>(), (TSource item, List< TResult> result) => { result.Add(converter(item)); return result; }); } }



지금까지 살펴본 고차 함수 Filter, Map, Reduce 모두 .NET 프레임워크 3.5에서 Enumerable 정적 클래스를 통해 <표 2>와 같이 동일한 기능을 제공한다. 앞서 직접 구현한 Filter, Map, Reduce를 .NET 프레임워크 3.5에서 제공하는 Enumerable 정적 클래스로 변경하면 <리스트 6>과 같다.





<리스트 6> .NET 프레임워크 3.5로 짝수의 제곱 합 구하기 using System; using System.Collections.Generic; using System.Linq; namespace _06_FilterMapReduce_NET { class Program { static void Main(string[] args) { int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; int sum = numbers .Print("All:") .Where(IsEven) // Filter .Print("Evens:") .Select(Square) // Map .Print("Squares:") .Aggregate(0, Add); // Reduce Console.WriteLine("Sum: {0}", sum); } static bool IsEven(int value) { return (value % 2) == 0; } static int Square(int value) { return value * value; } static int Add(int x, int y) { return x + y; } } static class Extension { 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; } } }



<리스트 6>을 컴파일해 실행하면 <리스트 5>의 실행 결과와 같은 결과(<그림 10>)가 나타난다. 이와 같은 결과를 통해 확인할 수 있는 것은 이미 C#에서 Filter, Map, Reduce와 같은 함수형 프로그래밍의 주요 고차 함수들이 .NET 프레임워크를 통해 제공되고 있다는 점이다. 같은 요구사항을 이번에는 함수형 언어인 F#을 이용해 구현해 보자. <리스트 7>의 F# 코드를 보면 특별한 설명 없이도 직관적으로 연산 흐름을 파악할 수 있을 것이다.



<리스트 7> 함수형 언어 F#으로 짝수의 제곱 합 구하기 [< EntryPoint>] let main argv = let IsEven x = x % 2 = 0 let Square x = x * x let sum = [1 .. 10] |> List.filter(IsEven) |> List.map(Square) |> List.sum printfn "%d" sum 0



객체지향 언어인 C#, Java, C++ 등에서 이미 함수형 프로그래밍의 주요 고차 함수를 제공하고 있는데 왜 굳이 함수형 언어인 F#, Scala 등이 필요한지 의문을 가질 수 있다. 지금까지 살펴본 것과 같이 고차 함수는 입력으로부터 전달된 책임(순수 함수)을 조합해 새로운 책임을 만들어 낸다. 이때 조합하는 과정 또한 순수 함수의 성질을 가진다. 기존의 명령형 언어들은 순수 함수를 언어적으로 지향하지 않는다. 그러나 함수형 언어는 순수 함수를 기반으로 문제 해의 특성을 바라보기 때문에 문제에 대한 접근 방향 자체가 다르다. 이에 대한 이야기는 다음 시간에 더 자세히 다룰 것이다.



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

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