이하의 글은 2012년에 쓴 것입니다. 오래된 글인 만큼, 현재의 생각과 전혀 다른 내용도 많이 포함되어 있고, 당시와는 상황이 많이 달라진 점도 있습니다. 또한, 그 당시에 잘못 알려졌던 정보도 포함되어 있을 수 있습니다. 어찌됐든 저는 제 오래된 글이 회자되는 것을 저어합니다. 읽기에 앞서 양해를 부탁드립니다.
익명 함수의 과한 사용
나는 익명 함수가 과하게 사용되고 있다고 생각한다. 없으면 못하는 것들이 있긴 하지만, 그 가운데서는 익명 함수가 아니라 더 높은 수준에서 추상화가 되어야 한다고 여겨지는 것들이 있는데, 몇몇 언어는 그 의무를 이행하지 않는 대신에 그 자리에 익명 함수를 쓰라고 한다. 결국 이것도 언어의 추상화에 틈이 생겨서 나타나는 디자인 패턴이라고 할 수 있다.
이런 생각을 사실 독립적으로 하고 있었는데, 마침 오늘 박영록 님이 다음과 같은 트윗을 올려서 생각난 김에 블로그에 내 생각을 정리해보기로 했다.
파이썬은 익명 함수가 부실해서 아쉽다고 생각하면서도 정작 파이썬을 쓸 때는 그런 생각이 들지 않는 건 왜일까? 파이썬 쓸 땐 이미 파이썬스럽게 뇌 구조가 바뀌나? 아니면 lambda와 list comprehension으로 대충 때워져서?
— Pak Youngrok (
RAII
익명 함수의 흔한 용도 중 하나는 리소스 관리다. Java 6 이전에 파일을 쓸 때는 다음과 같이 했었다.
try {
FileOutputStream fs = new FileOutputStream("filename.txt");
// deals with fs
} catch (IOException e) {
// exception handling
} finally {
try {
fs.close();
} catch (IOException e) {
// exception handling
}
}
보다시피 finally
에 해제에 관련된 코드를 직접 써야 한다. 까먹고 fs.close()
를 안 쓰면? 그럼 그냥 안 닫고 마는 거다. 당연히 잠재적인 버그이다. Java 7에서 RAII가 도입되었고, 이제 이렇게 쓸 수 있다.
try (FileOutputStream fs = new FileOutputStream("filename.txt")) {
// deals with fs
} catch (IOException e) {
// exception handling
}
RAII를 지원하는 다른 언어로는 Python, C# 등이 있다. Python의 경우 다음과 같이 with
키워드를 쓴다.
with open('filename.txt', 'w') as fs:
pass # deals with fs
C#은 using
키워드를 쓴다.
using (FileStream fs = new FileStream("filename.txt", FileMode.OpenOrCreate)) {
// deals with fs
}
반면 Ruby의 경우 RAII를 언어 차원에서 문법적으로 해결하지 않고 각 리소스를 관리하는 객체들마다 람다를 이용해서 통일되지 않은 인터페이스로 제공한다. 주로 생성자에서 블럭을 받는 식이다. 이를테면 다음과 같다.
File.open("filename.txt", "w") do |fs|
# deals with fs
end
언어 차원에서 RAII를 쓸 수 있는 객체를 위한 인터페이스를 명확히 제시한다면 그게 가장 이상적이다. RAII를 구현하기 위해 블럭이나 익명 함수를 쓰는 것은 몇가지 단점이 존재한다. 첫번째는 자원을 관리하는 객체마다 인터페이스가 쉽게 달라진다는 점이고, 두번째는 인터페이스가 다르기 때문에 조합 가능하지 않거나 조합이 성가시다는 점이다. 예를 들어 Python 표준 라이브러리에 있는 contexlib
모듈은 nested()
나 closing()
같은 것들을 제공하는데 이런 것들이 단일 인터페이스가 존재하기 때문에 가능한 조합 유틸리티들이다.
마지막으로는 좀더 일반적인 이야기이고, 이 글 전체에서 주장할 내용인데, 자주 나오는 패턴은 더 높은 수준의 추상화로 감싸는 게 맞다. 그 추상화를 사용하는 문맥에서 기대하는 것들을 좀더 잘 정련할 수 있기 때문이다. 예를 들어, __getattr__
같은 매직 메서드가 있으면 프로퍼티(property) 같은 것들을 구현할 수 있다. 그리고 실제로 아주 오래전에 쓰인 Python 코드는 그렇게 프로퍼티를 구현했다. 하지만 오늘날의 Python 코드는 정말 유동적인 애트리뷰트를 흉내낼 것이 아니면 모두 표준에 존재하는 property
나 디스크립터(descriptor) 프로토콜을 사용한다. 물론 이것들도 내부적으로는 매직 메서드와 같은 것을 이용해서 구현될 수 있다. 하지만 property
를 제대로 사용하는 쪽이 코드의 의도도 훨씬 명확하고 같은 기능을 훨씬 싸게 구현할 수 있게 해준다. 그리고 언어 차원에서는 이런 언어적인 프로토콜을 위해 추가적인 최적화도 해줄 여지가 있다. (실제로 해주진 않지만.) 사용되는 문맥에서 보장할 수 있는 전제 사항이 많고 확실해질 수록 최적화는 쉬워지는 경향이 있다.
제너레이터(generator)와 코루틴(coroutine)
많은 IoC가 제너레이터나 코루틴으로 대체 가능하다는 얘기는 이 블로그에서 질리도록 했으므로 짧게 설명하겠다.
(0...10).each do |i|
# deals with i
end
위와 같은 코드가
for i in xrange(0, 10):
pass # deals with i
이렇게 쓰일 수 있다. 이건 매우 간단한 예제라서 그냥 반복자 예제처럼 보일 수도 있다. 그래서 또 다른 예제.
var request = function (i) {
http.get({ hostname: 'blog.dahlia.kr', path: '/?page=' + i }, function (response) {
response.on('data', function (chunk) {
// deals with chunk
if (i < 10) {
request(i + 1);
}
});
});
};
request(1);
위 예제는 전형적인 IoC 스타일이다. 코루틴을 쓰면 그냥 일반적인 코드로 바뀐다.
for i in xrange(10):
response = urllib2.urlopen('http://blog.dahlia.kr/?page=' + str(i))
for chunk in response:
# deals with chunk
순차열을 다루는 고차 함수
또 다른 패턴은 map
, filter
, foldl
같이 순차열을 다루는 고차 함수들을 사용할 때이다. 예를 들어 Python에서는 다음과 같이 쓰인다.
names = map(lambda person: person.name,
filter(lambda person: 20 <= person.age < 30, people))
이러한 고차 함수의 대안으로는 list comprehensions, generator expressions나 완전판인 LINQ 같은 게 있다. Haskell이나 Python 같은 언어는 list comprehensions를 제공하고, 그래서 map()
이나 filter()
함수가 존재하긴 하는데 가급적이면 list comprehensions를 쓰라고 문화적으로 권장한다.
name = [person.name for person in people if 20 <= person.age < 30]
이런건 진부한 예제이지만, 조금 색다른 예제를 들자면 any
나 all
같은 것이 있겠다. 이를테면 다음 Ruby 코드는
people.any? {|person| person.age.between?(20, 29) }
generator expressions를 쓴 다음 Python 코드에 대응된다.
any(20 <= person.age < 30 for person in people)
어느쪽의 코드가 더 의도가 잘 드러나고 읽고 고치기 쉬운지는 주관적인 판단이겠지만, 대부분의 경우 순차열에 대한 고차 함수는 지연 평가가 되는 list comprehensions로 대체 가능하다.
Lisp에서는 car
/cdr
로 재귀 순환을 하거나 map
이나 filter
를 날로 쓸 것 같지만 실제로는 Common Lisp 같이 성숙한 Lisp은 loop
같은 매크로가 존재해서 list comprehensions 역할을 수행한다 (실제로는 Common Lisp답게 그보다 훨씬 많은 기능을 제공한다).
함수를 조작하는 고차 함수
Python에만 있는 기능이긴 하지만, 함수를 조작하는 고차 함수의 경우 데코레이터(decorator)로 대체할 수 있다. 데코레이터는 간단하게 설명하자면,
@deco
def func():
pass
위 코드의 @deco
를 데코레이터라고 한다. 그리고 위 코드는 다음과 같이 번역된다.
def func():
pass
func = deco(func)
데코레이터는 일반적인 표현식이므로 인자를 받거나 인덱스 연산자를 쓰거나 하는 게 된다. 예를 들어
@deco.xyz
def func():
pass
@deco(xyz)
def func2():
pass
위 코드는 다음과 같이 번역된다.
def func():
pass
func = deco.xyz(func)
def func2():
pass
func2 = deco(xyz)(func2)
활용법이 무궁무진하고 실제로도 굉장히 여러 용도로 쓰이는 문법인데, 사실 이 글의 제목이 무색하게 Python에서도 데코레이터는 과하게 사용하고 있다는 느낌도 든다. (이에 관해서는 기회가 된다면 비슷한 형식의 글을 또 올리겠다.) 하여간에 간단한 프로퍼티 정의 코드를 고차 함수 호출에서 데코레이터 사용으로 바꿔보자면 다음과 같다.
def _get_name(self):
row, = self.db.query('SELECT name FROM people WHERE id = ?', (self.id,))
return row[0]
def _set_name(self, name):
self.db.execute('UPDATE people SET name = ? WHERE id = ?', (name, self.id))
name = property(_get_name, _set_name)
위 코드는 보통 잘 쓰이지 않고 아래와 같이 데코레이터 형식이 더 많이 사용된다.
@property
def name(self):
row, = self.db.query('SELECT name FROM people WHERE id = ?', (self.id,))
return row[0]
@name.setter
def name(self, name):
self.db.execute('UPDATE people SET name = ? WHERE id = ?', (name, self.id))
함수형 프로그래밍은 함수형 언어에서
마무리를 지으며 얘기하자면, 익명 함수가 있다고 함수형 언어가 되지는 않으며 고차 함수를 쓴다고 함수형 프로그래밍이 되는 것도 아니다. 함수형 프로그래밍은 단지 익명 함수나 고차 함수에 관한 것이 아니며, 진지한 함수형 프로그래밍은 불변 자료구조에서부터 절차형 프로그래밍 언어에서와는 다른 여러가지 방법들을 요구한다. 현대적인 언어들은 모두 익명 함수를 지원하고 고차 함수를 쓸 수 있지만, 실제로 그런 언어들로 함수형 프로그래밍을 하기에는 제대로 구현된 자료 구조도 없다. 이를테면 문자열이 아예 변경 가능한 경우도 있고, 불변이라고 하더라도 무식하게 매번 복제되는 식으로 구현된다. 이를 막기 위해 그런 언어에서는 스트림 같은 별도의 자료형을 쓰도록 요구하는데 이런 것들이야 말로 전형적인 절차적 프로그래밍의 한 측면이라고 볼 수 있다.
절차적 언어에서 함수형 스타일로 코딩하는 것은 일부 매우 시의적절한 상황을 제외하면 효율이나 가독성 모두 손해를 보는 경우가 대부분이라고 생각한다. 함수형 프로그래밍으로 디자인하는 것이 매우 적절한 상황(예를 들면 컴파일러 구현)이라면 아예 Ocaml이나 Clojure 같은 함수형 언어로 프로그래밍하는 것을 고려해야 할 것이다.
일부 개발자들이 디자인 패턴을 겉핥기 식으로 이해하여 몇몇 이디엄들을 상황 판단을 제대로 하지 못하고 아무 때나 남발하던 시절이 있었던 것처럼, 요즈음은 고차 함수라는 이디엄이 과하게 사용되는게 아닌가 싶은 생각이 들어서 (나도 몇년 전에는 확실히 그랬었던 것 같다) 이렇게 글로 정리해본다.