함수형 프로그래밍 기초 (1) 왜 함수형 프로그래밍인가

🗓 ⏰ 소요시간 21 분

함수형 패러다임

새로운 프로그래밍 패러다임에서 중요한 것은 새로운 언어를 배우는 것이 아니라 새로운 방식으로 생각하는 법입니다. 문법을 배우는 건 쉽지만 사고방식을 배우는 건 쉽지 않죠.

컴퓨터 과학에서는 수십 년 전의 아이디어가 갑자기 주류가 되곤 합니다. 객체지향은 1983년 Simula 67이라는 언어에서 처음 등장했지만 1983년 처음 등장한 C++이 보편화된 후에야 주류가 되었습니다. Java 는 초창기에 메모리를 많이 사용해 고성능 애플리케이션에는 적합하지 않았지만 하드웨어가 발전하면서 선호도가 높아졌습니다.

함수형 언어도 마찬가지입니다. 1930년대 람다 대수 라는 수학적 표기 방식이 함수형 프로그래밍의 기반이 된 이후 Lisp 라는 함수형 프로그래밍 언어도 만들어졌습니다. 리스프는 현대적 함수형 프로그래밍의 여러 특징을 가지고 있습니다.

한동안 객체지향 언어가 유행이었지만 연봉 높은 프로그래밍 언어 순위 2018 을 보면 순위 TOP 10의 언어들은 거의 함수형 언어이고, 주요 언어들도 함수형 기능을 추가하고 있습니다. JavaScript 는 이미 많은 함수형 기능을 지원하고, C++은 2011년 표준에서 람다 블록을 추가했으며, 자바조차도 자바 8에 람다 블록을 도입했습니다. 앞서 이야기한 것처럼 문법 자체가 중요한 것은 아닙니다. 함수형으로 사고방식을 바꾸면 이런 기능을 바로 사용할 수 있습니다.

객체 지향 프로그래밍과의 차이

객체지향은 동작하는 부분을 캡슐화해서 이해할 수 있게 하고, 함수형 프로그래밍은 동작하는 부분을 최소화해서 코드 이해를 돕는다.

마이클 페더스‘레거시 코드 활용 전략' 저자

객체지향과 함수형의 차이는 상태를 관리하는 점입니다. 객체지향의 경우 객체 안에 상태를 저장합니다. 그리고 해당 상태를 이용해서 제공할 수 있는 기능(메소드)를 추가하고 상태 변화를 ‘누가 어디까지 볼 수 있게 할지’를 설정하고 조정하기 위해 캡슐화, scoping, visibility 등의 기능을 사용합니다. 여기에 쓰레드까지 더해지면 더 복잡해지는데요. 함수형 프로그래밍은 이런 상태를 제어하기보다는 상태를 저장하지 않고 없애는데 주력합니다. 함수라는 것 자체가 입력값이 들어가면 이에 따른 특정한 출력값이 나오는 것으로 상태를 저장하지 않습니다.

따라서 매우 간결하게 코드를 작성할 수 있고 이를 위해서는 객체지향과는 다른 방식으로 접근해야 합니다. 객체 지향은 상태를 저장하는 필드와 필드를 이용해 기능을 제공하는 메소드를 붙여서 클래스를 만듭니다. 항상 새로운 자료구조를 사용하게 되는 셈입니다. 하지만 함수형은 몇몇 자료구조(list, set, map)를 이용해 최적화된 동작을 만들어냅니다.

예제) 단어 수 세기

객체 지향과 함수형의 차이를 예제로 살펴보겠습니다. 텍스트 파일을 읽고 가장 많이 사용된 단어를 찾은 후 그 단어와 빈도를 정렬된 리스트로 출력하는 예제입니다.[1]

Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Words {
private Set<String> NON_WORDS = new HashSet<String>() {{
add("the"); add("and"); add("of"); add("to"); add("a");
add("i"); add("it"); add("in"); add("or"); add("is");
add("d"); add("s"); add("as"); add("so"); add("but");
add("be");
}};

public Map wordFreq(String words) {
TreeMap<String, Integer> wordMap = new TreeMap<>();
Matcher m = Pattern.compile("\\w+").matcher(words);
while (m.find()) {
String word = m.group().toLowerCase();
if (!NON_WORDS.contains(word)) {
if (wordMap.get(word) == null) {
wordMap.put(word, 1);
} else {
wordMap.put(word, wordMap.get(word) + 1);
}
}
}
return wordMap;
}
}

루프를 돌면서 단어인지 아닌지 체크하고 맵을 이용해 갯수를 카운트합니다.

Java 8

자바 8의 스트림과 람다를 이용해보죠. 정규식 결과를 리스트로 바꿔서 스트림을 생성합니다. 스트림을 생성하면 아주 간결하게 처리할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public Map wordFreq(String words) {
TreeMap<String, Integer> wordMap = new TreeMap<>();
regexToList(words, "\\w+").stream()
.map(String::toLowerCase)
.filter(w -> !NON_WORDS.contains(w))
.forEach(w -> wordMap.put(w, wordMap.getOrDefault(w, 0) + 1));
return wordMap;
}

private List<String> regexToList(String words, String regex) {
List wordList = new ArrayList();
Matcher m = Pattern.compile(regex).matcher(words);
while (m.find())
wordList.add(m.group());
return wordList;
}

예제) 문자 위치 찾기

Java

이번 예제는 주어진 배열 내에서 해당 문자가 처음 발견되는 위치를 리턴하는 예제입니다. 소스는 Apache CommonsStringUtilsindexOfAny 라는 메소드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static int indexOfAny(final CharSequence cs, final char... searchChars) {
if (isEmpty(cs) || ArrayUtils.isEmpty(searchChars)) {
return INDEX_NOT_FOUND;
}
final int csLen = cs.length();
final int csLast = csLen - 1;
final int searchLen = searchChars.length;
final int searchLast = searchLen - 1;
for (int i = 0; i < csLen; i++) {
final char ch = cs.charAt(i);
for (int j = 0; j < searchLen; j++) {
if (searchChars[j] == ch) {
if (i < csLast && j < searchLast && Character.isHighSurrogate(ch)) {
// ch is a supplementary character
if (searchChars[j + 1] == cs.charAt(i + 1)) {
return i;
}
} else {
return i;
}
}
}
}
return INDEX_NOT_FOUND;
}

로직 자체는 단순하지만 복잡하게 구현되어 있습니다. 이중 for 문과 중첩된 if 문 때문에 복잡하게 보입니다.

Scala

이번엔 동일한 기능을 Scala 를 이용해서 구현해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
def firstIndexOfAny(input: String, searchChars: Seq[Char]) : Option[Int] = {
def indexedInput = (0 until input.length).zip(input)
val result = for (pair <- indexedInput;
char <- searchChars;
if char == pair._2) yield pair._1

if (result.isEmpty)
None
else
Some(result.head)
}

먼저 0 until input.length 는 입력된 문자열만큼 자연수 컬렉션을 만들고 zip() 메소드를 이용해 숫자와 문자를 각각 매핑시킵니다. 만약 입력 문자가 “zzabyycdxx” 라면 0~9까지 숫자가 만들어지고 각 문자와 맵핑되어 다음과 같은 컬렉션이 만들어집니다. 앞에 숫자를 인덱스로 사용할 목적입니다.

1
Vector((0,z), (1,z), (2,a), (3,b), (4,y), (5,y), (6,c), (7,d), (8,x), (9,x))

그리고 for 문을 이용해서 같은 문자를 찾으면 인덱스를 반환합니다. 마지막으로 null 문제를 피하기 위해서 Option 객체를 사용합니다. Option 객체의 하위 객체에는 값이 있는 Some과 값이 없는 None이 있습니다. 결과 값을 확인해서 해당 객체를 리턴합니다.

이번 포스트에서는 함수형 프로그래밍에 대해 간단하게 알아봤습니다. 앞으로 좀 더 자세한 내용과 함수형 사고방식을 다루겠습니다.

참고


  1. 1.CACM의 고정 칼럼인 Programming Perls 의 기고자 존 벤틀리가 초기 컴퓨터과학의 개척자 도널드 커누스에게 요구했던 작업.