여러 유명한 반(反) OOP 글들을 훑어보고, 그 주장들을 정리·비판하면서 객체지향 프로그래밍이 왜 구조적으로 문제적인지, 그리고 타입 이론·함수형/논리형 패러다임이 어떤 대안을 제시하는지 설명한다.
아무도 안 읽을 것 같은 글이다. 우리는 이 얘기를 이미 한참 전에 지나왔다. OOP는 한 번 벽에 던져 놓으면 계속 들러붙어 있는 그런 물질 같은 것이다. 이 주제로 글을 쓰는 건 거의 항상 무의미하다. 배워야 할 사람들은 배우기를 거부하고, 나머지 사람들은 이미 자기 교훈을 얻었기 때문이다.
여기서는 Matthew MacDonald가 쓴 "The Case Against OOP is Wildly Overstated"를 검토하고 비판한다. 그 글은 몇몇 다른 글들을 인용하고 있는데, 그 글들도 똑같이 다룰 것이다. 아마 다음 글들은 한두 번쯤 봤을 것이다.
이 글들에서 주요 주장만 뽑아서 훑어볼 것이니, 굳이 전부 직접 읽을 필요는 없다. 추가로 이런 것들도 다룬다.
인터넷이라는 하수구에서 이런 보석을 보는 날도 흔치 않다. 한번 들여다보자!
각 글마다 그들이 말한 핵심 포인트를 훑어본다. Ilya의 글이 이 중에서 가장 길고(읽기 27분), 그다음이 Karsten의 글이다.
내 의견이 중간중간 섞여 있고, 여기서 주워 온 내용들이 글의 나머지 부분에서 다룰 주제들의 토대가 된다.
Ilya SuzdaInitski는 많은 주장을 하지만, 증거를 제시하는 데에는 별로 신경 쓰지 않는다. 그 대신 정말 많은, 아주 많은 주장을 쏟아낸다. 그래도 유명한 반(反) OOP 자료들을 많이 인용하고 있어서 완전한 낭비는 아니다. 이 모음 중에서는 그나마 구조가 잘 잡혀 있어서 훑어보기 쉽다.
이 글의 하이라이트는 Edsger W. Dijkstra의 명언 "Object oriented programs are offered as alternatives to correct ones..." 이다. 그 부분은 나도 웃었다. 그래, Dijkstra는 correctness에 집착했고, 나도 결국 비슷한 쪽으로 와버린 것 같다.
해당 글의 주장들:
눈에 띄는 인용/참조:
우선, Alan Kay의 클래스/객체 발언은 OOP를 공격하는 데 쓸 수 없다. 클래스와 객체는 Simula라는 언어에 등장했고, 이후로 줄곧 이어져 왔다. 그게 더 나은 무언가에서 영감을 받았다고 해서 그 자체가 무효가 되지는 않는다. OOP는 자기 자신에 지나치게 과대 평가된 데이터 타입 커스터마이징 기능일 뿐이다. 수십 년 전의 구현 세부사항을 노출해 놓고, 그걸 프로그래밍의 주된 모델로 삼아 버린 것이다.
OOP를 그만두면 공유 가변 상태를 자동으로 버리는 것처럼 생각하는 건, 사람들을 계속 묶어두는 착각이다. "불변" OOP를 해도, 나아지는 게 없다!
분류 체계(taxonomy)와 그런 걸 통해 세계를 이해하려는 시도 자체는 OOP의 문제가 아니다. 게다가 이 얘기를 꺼내면, 상대는 곧장 "차/바나나 같은 클래스를 쓰면 안 되고…" 라고 말할 텐데, 그 말은 Ilya가 말한 것만큼이나 틀렸다.
OOP는 처음부터 장황했다. 그럼에도 불구하고 널리 퍼졌다. 이 장황함으로 대가를 치르고 있다고 가정돼 있는 셈이라, "신호 대 잡음 비가 나쁘다"는 불평 정도는 그냥 눈만 굴리고 말 일이다.
리팩터링은 도구로 한다고 들을 것이다. 그리고 인터페이스를 아무 데나 선언해서 전부 unit test 가능하게 만들 수 있다고 말한다. IDE가 보일러플레이트 코드를 리팩터링해 주니까 신경 안 쓴다고. 인터페이스와 목을 도처에 깔아도 문제 없다고.
단위 테스트와 OOP에 대한 주장에 관해서는 여기서 깊게 들어가진 않겠다. 나는 아직도 내 코드에 단위 테스트를 거의 안 붙인다. 지금은 반대하는 건 아니지만, 잘 알지도 못한다. 뭔가가 올바르다는 걸 테스트로 확인하는 것보다, 형식적으로 증명하는 쪽이 쉬운 편이라서 그렇다.
OOP는 Java보다 먼저 나왔다. C++가 Java가 유행하기 전부터 뜨겁게 인기 있던 객체지향 언어였다.
Charles Scalfani는 한때 "상속, 캡슐화, 다형성의 이득을 십분 활용"하려고 매우 의욕적이었다. 하지만 그는 결국 비행기가 자기 집 앞마당에 착륙해 주지는 않는다는 사실에 실망했다.
이 사람들은 OOP의 기둥을 집중적으로 공격한다. 그래서 Grady Booch의 책을 찾아보게 된 것이다.
이제 이런 taxonomy랑 "지도" 얘기도 해야 할 것 같다. 프로그래머 버전의 "벌과 꽃" 교육이 될까?
깨지기 쉬운 기반 클래스 문제 자체는 이미 충분히 다뤄진 주제라서 여기서는 따로 언급하지 않는다. 요약하면 이렇다. 겉보기에는 안전해 보이는 기반 클래스의 변경이 파생 클래스들을 망가뜨릴 수 있다. 프로그래머는 기반 클래스를 따로 떼어 놓고 보기만 해서는 그 변경이 안전한지 판단할 수 없다.
그는 인터페이스 기반 다형성을 대안으로 언급하지만, 그게 뭔지 설명도 안 하고 링크도 안 단다!
Elm은 지금은 웃음거리 신세가 되었다. 타입클래스를 건너뛰고, 언젠가 더 나은 대안이 나타나길 기다리기로 했다. 지금까지도 기다리는 중이고, 그 사이에 라이브러리는 꽤나 불어났다. 초보자에겐 단순해 보이도록 여러가지를 해 놨지만, 사용자를 붙잡아 두는 데는 실패했다. 언어가 스스로의 "베스트 프랙티스"를 강제하는데, 튜플 크기를 제한해 동차 좌표(homogeneous coordinate) 구성을 방해하고, 릴리스 모드에서 undefined/absurd를 금지하는 식으로 당신을 방해한다.
다음은 Idris의 absurd 함수다. Elm에서 릴리스 모드에서 막는 것이 대략 뭘 의미하는지 느낌을 잡을 수 있을 것이다.
idrisabsurd : Uninhabited t => t -> a
비종속(non-dependent) 언어에는 이런 게 없지만, 비슷한 목적을 위해 undefined 같은 게 있다. Elm에도 있지만 디버그 모듈에만 있고, 릴리스에선 사용할 수 없다. 프로그램에서 그런 일이 벌어지면 정말 심각하게 잘못된 상태라는 걸 이미 알고 있을 때, 이 함수를 maybe로 감싸고 maybe 모나드 없이 처리하거나, 임시 값으로 때워야 하는 건 상당히 귀찮다. Elm은 그냥 Nothing이라고만 말한다!
Konrad Musial은 한때 OOP에 들떠 있었던 경험을 들려준다. 원과 타원을 속성들을 가진 객체로 배우고, 그걸로 흥분했던 시절. 이 글은 OOP를 이해하려 고군분투한 이야기다. 예제로 C# 코드를 조금 보여준다. 대충 보면 별로 복잡하진 않은데, 여기처럼 [Serializable] 같은 attribute가 뿌려져 있다.
csharp[Serializable] public class SampleClass {}
이건 엄밀히 말해 OOP도 아니다. C#에서 코드에 메타프로그래밍을 위한 선언과 구조를 더 붙이는 방식이다. C#의 대표적인 기능 중 하나로, 이 언어를 쓸 거라면 꼭 알아야 하는 기능이다.
글은 유명한 반 OOP 인용들로 가득 차 있고, 끝에 가서는 결국 자기가 이해를 마쳤고 OOP로 다시 돌아갔다고 말한다. 일주일인가 이주일 뒤에는 "Why OOP is Awesome"이라는 글을 썼다. 당신이 뭔가를 이해하지 못했다고 해서 그게 잘못되었거나 나쁜 건 아니다.
이 글은 산문 형식이고, 이 모음 중 이 글뿐이 아니다. 길이는 가장 짧은 축에 속하지만, 읽기가 제일 힘들었다. 한 번에 통째로 읽어야 하기 때문이다. 나는 근래에야 내가 어떻게 글을 쓰는지에서 설명한 식으로, 대충은 쉽게 훑어볼 수 있는 글을 쓰는 방법을 스스로 깨달았다. 완벽하다고는 안 하겠지만, 이 글 역시 거의 그런 스타일로 쓰이고 있다.
Karsten Wagner는 OOP가 정점을 찍고 이제 내리막이라고 생각한다. 함수형 언어와 클로저/컨티뉴에이션 같은 개념에 대한 관심이 늘어나는 상황에서, 기존의 "OO" 언어들은 이런 기능들을 스스로 흡수하기 시작했다고 본다.
그가 OOP가 약속을 지키지 못했다고 생각하는 이유들:
this 파라미터가 지나치게 특별하다. 여러 파라미터에 동시에 작용해야 할 때 생기는 문제들을 지적한다.String 클래스를 직접 구현하지 않는 이상, 그 String 클래스에 자기만의 .trimLeft를 추가할 수 없다. 대신에 String.trimLeft 같은 걸 만들어야 한다.어느 집단에서는 OOP 사용이 줄어든 것 같긴 하다. 타입 이론과 형식 검증을 이해하는 사람이 14년 전보다 훨씬 많아졌다. Haskell은 드디어 TIOBE 인덱스 40위다!
큰 그림에서 보면 OOP는 아주 잘 나간다. 프로그래머 수가 사상 최고니까! 그들은 Uncle Bob의 야간 독서 목록을 소화하고, 디자인 패턴과 SOLID, 그 밖의 온갖 그럴싸한 OOP 이론을 배우고 있다. 언어 차원에서도 OOP는 호황기다. Javascript나 Python 같은 인기 언어는 커뮤니티 주도로, 대화와 합의에 기반해 방향이 정해진다.
나도 한때는 사람들이 OOP 언어를 비-OOP 식으로 쓰기 시작할 거라고 믿었는데, 아직 완전히 그렇게 되지는 않았다. 여전히 OOP를 두고 논쟁하는 중이고, 아직 그 단계를 못 넘었다.
클래스에 붙어 있는 메서드 집합이 굉장히 경직되어 있다는 건 실제 문제지만, 보통은 무시된다. 어차피 모듈에서 trimLeft를 import해야 하니까, 별 문제가 아니라는 식이다.
모나드로 상호작용 프로그램을 작성할 때, 변이가 사라지는 건 아니다. 모나딕 IO는 가변 구조를 프로그램의 가장자리로 밀어낼 뿐, 그 자체나 그에 상응하는 무언가는 여전히 갖고 있다. 이 점에 대해서는 내가 쓴 "IO 모나드를 이해하고 Haskell에서 직접 구현해 보자"에서 설명했다.
그는 패턴 매칭이 다중 디스패치를 대체한다고 보는 것 같은데, 실제로는 그렇지 않다. 다중 디스패치는 또 그 나름대로 작동하지 않는다. 더 나쁜 건, 이게 꽤 써 본 뒤에야 문제가 드러난다는 점이다. 나는 내 lever 프로그래밍 언어에서 다중 디스패치를 시도해 봤고, 결말은 좋지 않았다.
적어도 14년 전쯤에는 참조 투명성이 중요하다는 걸 이미 깨달았다는 점은 고맙게 생각한다. 이제 개발자들 앞에서 누가 "수학적 식!"을 계속 외쳐 주면 좋겠다.
Matthew MacDonald의 요지는, 왕이 되면 적을 부르는 법이라는 것이다. 이 많은 블로그 글들과, 그 뒤에 숨어 있는 더 많은 글들을 보라는 식이다.
이 글 자체에 대해 딱히 할 말은 많지 않다. 제목은 꽤 잘 지은 편이다. 저자의 의견을 정확히 드러낸다(다만 "wildly"는 좀 과장). 저자는 사실 OOP에 대한 비판이 실제로 존재한다고 인정하면서도, 그 비판이 과장됐다고 보는 셈이다.
ORM이 구리다는 사실을 사람들이 드디어 이해한 건 반갑다.
OOP가 현실 세계를 모델링하는 게 아니었다는 흔한 주장도 등장한다. 이건 사람들이 배운 것을 꽤 엄격하게 따라 하기 시작했다는 게 명백해지자, 뒤늦게 등장한 해명이다. "원래 그렇게 배우라고 했던 게 아니다"라는 식의 핑계. 나도 예전에 C++로 만든, 원칙적인 OOP 계산기 프로그램을 아직 기억한다. IRC에서 누가 그걸 칭찬하기도 했었다. 그들 모두 틀린 셈이다. 어떤 taxonomy를 만들든, 그게 당면한 문제를 푸는 데 기여한다면 괜찮다.
도구를 탓하지 말라는 조언은 좋은 조언이다. 도구는 탓하지 말자… 그건 나한테 맡겨라. 나는 프로 도구-탓쟁이다!
Quora에 따르면, OOP의 4대 기둥이라는 말은 Grady Booch가 쓴 『Object-oriented Analysis and Design with Applications』(Addison-Wesley Professional, 1990)에 기원을 둔다고 한다.
간단한 인상:
나는 1994년에 나온 2판을 구해서, Booch가 말하는 추상화, 캡슐화, 상속, 다형성이 정확히 무엇인지 살펴봤다.
또한 이 책에서 클래스/객체 구조와 프로그래밍 언어를 어떻게 다루는지도 궁금했다. 내가 좀 더 똑똑했으면, 이 부분을 더 깊이 파고들었을지도 모른다.
책 전체를 뒤질 생각은 없었고, 다행히 용어 사전이 있다. 이 네 용어를 설명하고 있다. 아무 책이든 근본 경전으로 취급할 수는 없지만, 최소한 기준점은 얻을 수 있다.
abstraction(추상화)
"어떤 객체를 그 밖의 모든 종류의 객체와 구별 짓는 본질적 특성들, 그리고 이러한 특성들에 의해 관찰자의 관점에서 명확히 정의된 개념적 경계가 제공되는 것; 객체의 본질적 특성에 초점을 맞추는 과정. 추상화는 객체 모델의 근본적 개념 중 하나이다."
encapsulation(캡슐화)
"어떤 추상화의 구조와 행위를 구성하는 요소들을 하나의 구획 안에 집어넣는 과정; 캡슐화는 한 추상화의 계약적 인터페이스와 그 구현을 분리하는 역할을 한다."
inheritance(상속)
"클래스들 사이의 관계로, 한 클래스가 하나(단일 상속) 또는 여러 개(다중 상속)의 다른 클래스들에 정의된 구조와 행위를 공유하는 것. 상속은 클래스들 사이에 'is-a' 계층을 정의하며, 여기서 서브클래스는 하나 이상의 일반화된 슈퍼클래스로부터 상속받는다. 서브클래스는 보통, 슈퍼클래스의 기존 구조와 행위를 확장하거나 재정의함으로써 특수화한다."
polymorphism(다형성)
"타입 이론의 개념으로, 하나의 이름(예를 들어 변수 선언)이 어떤 공통 슈퍼클래스에 의해 서로 연관된 여러 다른 클래스의 객체들을 가리킬 수 있는 것. 따라서, 이 이름이 가리키는 어떤 객체든 공통된 연산 집합에 대해 서로 다른 방식으로 응답할 수 있다."
Booch가 오늘날 OOP를 어떻게 보는지도 궁금하지만, 2009년 Booch 인터뷰에 따르면, 당시에는 여전히 Java와 PHP를 Eclipse에서 쓰고 있었다.
책에는 이런 목록이 있다. Weigner가 인기 있는 고급 언어들을, 처음 도입한 언어 기능에 따라 세대별로 분류한 것이다.
1세대 언어
2세대 언어
3세대 언어
세대의 공백기(1970–1980)
1세대 언어 목록은 좀 웃기다. 이 리스트를 액면 그대로 믿는다면, 기묘하게도 정확하다. Lisp의 위치도 꽤 우습다.
잘은 모르겠지만, Booch는 프로그래밍 언어의 형태를 거의 주어진 것으로 받아들였던 것 같다. "추상화를 위한 수단이 이 정도 있으니, 이걸 최대한 잘 써야 한다"는 식으로 말이다. 불행히도, 이런 생각을 뒷받침하는 구절을 찾지는 못했다. 만약 있었다면 OOP 논쟁이 여기서 끝났을지도 모른다.
그 밖에는 이 책의 구성 방식이 꽤 마음에 든다. 용어 사전과 참고 문헌이 덤(remnant)처럼 붙어 있는 게 아니라, 책의 일부로 잘 편성된 좋은 예다. 아마 나중에 다시 들춰 보면서, 콘텐츠를 어떻게 전달하는지 더 메모를 해 둘 것 같다.
이 글의 요지는 이렇다.
사람들이 기둥 위치를 옮기기 시작하면, OOP 자체가 서커스 공연처럼 보이기 쉽다. 기초 기둥으로 의자 뺏기 게임을 하는 느낌이다.
Booch의 책 예제로 OOP의 문제를 보여 주는 건 약간의 아이러니라, 여기 등장하는 OOP 예제들은 전부 그 책에서 가져왔다.
2020-08-03 추가: [hwayne][hwayne]이, ML이 CLU의 타입 시스템에서 영감을 받았고, CLU는 Simula에서 영감을 받았다는 점을 지적해 줬다. 역사적으로 보면, 다형성은 OOP에서 FP 쪽으로 "이주"한 셈이다.
[hwayne] : https://lobste.rs/s/bmzgvz/case against oop is wildly overstated#c f7arfr
객체지향 프로그래밍은 _데이터 타입을 더 자유롭게 커스터마이즈하고 싶다는 요구_에서 출발했다. 예전에는 레코드 자료형 하나만이 유일한 커스터마이징 수단이었다.
cstruct PersonnelRecord { char name[100]; int socialSecurityNumber; char department[10]; float salary; }
더 많은 추상화와 커스터마이징이 필요하다는 점이 인식되자, 클래스가 등장했다. 클래스는, 모든 객체가 공유하는 메서드와 구조를 정의할 수 있다는 점에서 레코드를 확장한 것이다.
cppclass PersonnelRecord { public: char* employeeName() const; int employeeSocialSecurityNumber() const; char* employeeDepartment() const; protected: char name[100]; int socialSecurityNumber; char department[10]; float salary; }
이렇게 객체의 상태를 캡슐화하는 것이 좋은 공학적 관행으로 여겨졌다. 파라미터 접근을 이런 식으로 분리해 두면, 나중에 레코드 구현을 완전히 바꿔도 이 객체를 쓰는 사람들은 내부 구현을 알 필요가 없다.
이 기능의 구현은 간단했다. karen.employeeName()은 실제로는 레코드의 필드를 직접 읽는 게 아니라, 그 역할을 하는 함수를 호출할 뿐이다. 구현하기도 쉽고, 다른 접근법보다 훨씬 저렴했다.
또 초창기에는 메서드에 일종의 네임스페이스를 제공하기도 했다. 다른 게 거의 없던 시절에는 이게 꽤 근사하고 미니멀하게 보였을 것이다.
오늘날에는 하드웨어와 훨씬 더 큰 거리를 두는 것이 가능해졌다. 이제도 굳이 평평한 레코드 구조 위에 추상화를 쌓아야 할 이유가 있을까?
처음에는 상속과 다형성을 따로 쓸까 했는데, 실제로는 같은 구조에서 제공된다. 상속이 다형성을 가능하게 한다.
통상적인 사용법은, 서로 다른 레코드 형식들 사이의 차이를 묘사하는 것이다. 다음은 기본 클래스 예제다.
cppclass TelemetryData { public: TelemetryData(); virtual ~TelemetryData(); virtual void transmit(); Time currentTime() const; protected: int id; Time timeStamp; };
기본 클래스는, 이 구조에 대해 무엇을 할 수 있는지와, 모든 구조가 공유하는 것들을 기술한다. 그런 다음 특정한 종류에 특화된 정보를 더해 확장한다.
cppclass ElectricalData : public TelemetryData { public: ElectricalData(float v1, float v2, float a1, float a2); virtual ~ElectricalData(); virtual void transmit(); float currentPower() const; protected: float fuelCell1Voltage, fuelCell2Voltage; float fuelCell1Amperes, fuelCell2Amperes; }
virtual 메서드들은 클래스마다 하나씩 있는 가상 메서드 테이블(가상 함수 테이블, vtable)을 통해 접근된다. 이건 어떤 객체의 클래스를 판별하고 승격(promote)하는 데 사용할 수 있는 포인터를 제공한다. 하지만 이걸 직접 쓰는 건 나쁜 스타일로 여겨진다. 클래스는 확장 가능해야 하기 때문이다. 클래스를 상속해 확장하면 vtable 포인터도 달라진다. 그러니 이 포인터만 보고 클래스 자체를 식별할 수는 없다. vtable들을 식별하려면, 그것들끼리 이어지는 체인이 있어야 한다.
궁금한 사람도 있을 것이다. 클래스는 어떻게 간단하게 구현될 수 있었을까? 클래스/객체를 구현하는 방법은 여러 가지가 있지만, 레코드 바로 위에 얇은 층을 얹는 방식으로도 구현할 수 있다.
클래스는 구조체로 번역되며, 각 구조체의 맨 앞에는 가상 테이블 포인터 하나가 붙는다. 상속으로 새 virtual 메서드를 선언할 수 있으므로, 이 포인터는 필수다.
cstruct EmptyClass { void *vtable; }; struct TelemetryData { struct EmptyClass super; int id; Time timeStamp; }; struct ElectricalData { struct TelemetryData super; float fuelCell1Voltage, fuelCell2Voltage; float fuelCell1Amperes, fuelCell2Amperes; };
static 메서드들은 직접 참조되며, 이런 평범한 절차(함수)로 번역된다.
cTelemetryData_TelemetryData(TelemetryData*); Time TelemetryData_currentTime(TelemetryData*); ElectricalData_ElectricalData(ElectricalData*, float v1, float v2, float a1, float a2); float ElectricalData_currentPower();
무언가가 virtual로 선언되면, 가상 메서드 테이블에 들어간다.
cstruct EmptyClass_vtable { // void *vtableParent; /* 악명 높은 'instanceof'를 구현한다면. */ }; struct TelemetryData_vtable { struct EmptyClass_vtable super; void (*deconstruct)(TelemetryData*); void (*transmit)(TelemetryData*); }; struct ElectricalData_vtable { struct TelemetryData_vtable super; }; static TelemetryData_vtable vtable_TelemetryData; static ElectricalData_vtable vtable_ElectricalData;
vtable의 타입과 실제 vtable 인스턴스를 헷갈리기 쉽다. 물론 이건 그 자체로 결함은 아니고, 클래스와 객체가 제대로 구현되어 있다면 그 구현 세부사항을 신경 쓰지 않아도 된다. ElectricalData가 생성될 때는 객체 안의 vtable 포인터가 &vtable_ElectricalData를 가리키도록 설정된다.
상속은 닫힌 정의(closed definition) 구조와 열린 정의(open definition) 구조를 모두 만들 수 있게 해 준다.
이 둘은 분리되어 있어야 한다. 그렇지 않으면 서로 얽혀 버린다. 이런 이유로 초기 OOP 언어들은 instanceof 같은 기능을 넣지 않았다. 사실 vtable을 이용하면 구현할 수 있었겠지만 말이다.
닫힌 구조를 만들려면, 태그를 붙이면 된다.
cenum { t_ElectricalData = 0, t_LightTracking, t_DoorLockData } TelemetryTag;
이제 telemetryTag가 t_ElectricalData일 때, 그것이 ElectricalData 혹은 그 서브클래스임을 보장할 수 있다.
cif (telem.tag == t_ElectricalData) { ElectricalData* elec = (ElectricalData*)telem; /* 여기에 뭔가를 한다.. */ }
Java가 instanceof를 도입하면서 상황은 달라졌다. 좀 더 편하게 이런 식으로 쓸 수 있게 된 것이다.
javaif (telem instanceof ElectricalData) { ElectricalData elec = (ElectricalData)telem; /* elec에 접근 */ }
instanceof는 곧장 OOP의 악명 높은, 남용되는 기능이 되었다. Java가 이것을 넣은 이유는 아마 가비지 컬렉션 도입과 관련된, 보다 안전한 메모리 관리의 부수 효과였거나, 방금 생겨난 객체 내성(object introspection) 도구를 활용하기 위해서였을 것이다. 이 기능이 도입되면서 생기는 문제에 대한 무지, 혹은 무시는 그 뒤를 스스로 메웠다.
만약 이유를 잘 아는 사람이 있다면, Java에 instanceof가 왜 들어갔는지 알려 달라. 여기에 링크를 추가하겠다.
이렇게 여기저기에서 덕지덕지 붙인 기능들은, 그 자체로 취약성을 만들어 낸다. 이런 구조들을 사용하는 방식이 컴파일러의 구현과 매우 강하게 결합되어 있기 때문이다. 추상화는 원래 이런 식으로 작동하면 안 되는데, 그래도 예의상 여기서는 일단 추상화가 유지되고 있다고 가장해 보자.
이 가짜 추상화 전통은 Java, C#에서도 이어진다. 이 언어들은 자체적인 가상 머신을 만들고, 잘-타입되고 컴파일된 언어라면 당연히 할 수 있는, 여러 플랫폼으로의 번역을 거부한다. 동봉된 가상 머신 위에서만 돌 수 있도록 설계된 것이다. 이런 점에서는 Python이나 Javascript 같은 untyped 언어와 다를 바 없이 행동한다.
가상 메서드는 다형성을 제공하고, 런타임에 행동을 선택할 수 있게 해 준다. 그런데 이 다형성이 자의적이라는 문제가 있다. 거의 아무 제약 없이 무엇이든 할 수 있다는 뜻이다. 이게 다른 상황이라면 좋은 거겠지만, 어떤 선택이 바람직한 프로그램 행동으로 이어지는지 알 수 없다는 점에서 문제다. 그걸 모르는 상태라면, 이 기능이 없는 편이 더 낫다.
게다가 객체지향 프로그램에서 잘-형성된(polymorphic) 다형적 프로그램을 작성하기 위한 규칙들은 꽤 복잡하다. 공변성과 반공변성 같은 개념을 제대로 이해해야 한다. 실제로는 당신도, 당신의 상사도 OO 프로그램이 다형성을 어떻게 써야 하는지 잘 모르는 경우가 많다. 그래도 이 기능은 계속 쓴다!
OOP는 서브타이핑(subtyping)에 기반을 둔다. 서브타이핑은, 누군가가 Cat을 달라고 하면 CatDog을 줄 수 있고, 그 사람은 그 중 Cat 부분만을 사용할 수 있다는 식의 법칙이다. 요구된 것보다 더 많은 정보를 가진 값을 넘길 수도 있고, 요청한 것보다 더 많은 정보를 담은 답을 받을 수도 있다.
타입들은 포함하는 "정보"의 양에 따라 순서를 갖는다. 이 순서를 보존하면, 즉 누군가 Cat을 제공하는 곳에 Animal을 요구하는 식이면 공변(covariant)이다. 순서가 반대라면, 예컨대 Animal을 넘겨야 하는 곳에 Cat을 제공한다면 반공변(contravariant)이다. 둘 다 허용하면 양변(bivariant)이고, 둘 다 상관없다면 불변(invariant)이다.
이 개념들은 헷갈리기 매우 쉽다. 솔직히 말해, 지금 이 설명도 완전히 정확한지 확신이 안 든다. 이걸 잘못 적용하면, 당신의 다형성은 그대로 폭발한다.
잘 작동하는 다형적 프로그램을 쓰는 훨씬 간단한 방법이 있다. 요령은, 다형적 프로그램이 자신의 다형적인 부분을 균일하게 다루도록 강제하는 것이다. 다형적 프로그램이 자신이 다루는 구조물 내부를 들여다볼 수 없게 만들면, 그 구조를 다루는 방식이 잘 정의된다. 이것이 매개변수 다형성이고, 함수형 언어에서 흔히 쓰는 다형성 형태다.
예를 들어, 이런 타입의 함수가 있다고 하자.
texta → a
이 경우, 함수는 어떤 식으로든 a의 내부를 열어 볼 수 없다. 유일한 통로는 함수 본체뿐이다. 하지만 이런 함수가 하나 더 얹히면 상황이 달라진다.
text(a → a) → (a → a)
이 타입을 가진 함수는, a를 두 번째 함수에 0번 이상 여러 번 통과시키는 방식으로만 다룰 수 있다는 걸 알 수 있다. 이런 식으로, 항상 필요한 만큼만 정보가 있는 객체들을 다루는 편이 훨씬 다루기 쉽고 추론하기 쉽다.
매개변수 다형성이 흥미로운 점은, 이게 깨지면 그 책임이 언어 설계자에게 있다는 것이다. 프로그래머 책임이 아니다.
매개변수 다형성을 깨뜨리는 가장 쉬운 방법은, 암묵적인 모나드 join을 도입하는 것이다. 이건 그 구조의 모나드성을 파괴하기도 한다.
textmaybe (maybe a) → maybe a promise (promise a) → promise a array (array a) → array a
첫 번째는 보통, 편의를 위해 Just(a)는 없애고 Nothing 상수만 두거나, 포인터로 만들어지는 모든 구조에 암묵적인 null을 부여해 쉽게 초기화하도록 함으로써 깨진다. 그렇게 되면 Nothing과 Just Nothing을 구분할 수 없어지고, 이 구조로 감싼 변수들에 대한 매개변수 다형성이 깨진다. maybe a나 a?가 어떤 null을 받는 상황을 생각해 보자. 만약 a가 maybe something이라면, 상위 구조가 그 null을 가로챈다. 이는 instanceof가 초래하는 문제와 비슷하게, a 안의 정보를 예측할 수 없는 방식으로 식별·해석해 버린다.
나머지 둘은 깨지는 경우가 더 드물다. 배열은 초기 언어들에서 깨지는 경우를 볼 수 있는데, JavaScript에서는 promise가 깨졌다. 이 문제는 악명 높은 Issue 94로 기록되어 있다.
함수형 프로그래밍에서는, 다형적 프로그램이 자신의 파라미터 내부를 들여다볼 수 없기 때문에, 패턴 매칭을 구조 기반 디스패치(dynamic dispatch)에 쓸 수 없다.
함수형 언어에서 패턴은, 닫힌 정의(closed definition)를 가진 구조에 적용된다. 즉, 이 구조를 만들 수 있는 모든 방법이 타입 정의에 의해 완전하게 결정된다.
haskelldata Form = A | B | C
이 구조를 살펴볼 때, 잘-형성된 프로그램은 가능한 모든 경우를 반드시 처리해야 한다.
haskellcase form of A -> 1 B -> 2 C -> 3
이 분리는 훨씬 간단한 프로그램 모델을 보장한다. 동시에 OOP의 상속이 맡던 역할 일부를 대체하기도 한다. 이렇게 닫힌 정의의 객체들을 만들 수 있기 때문이다.
동적 디스패치는 실제로는, 모듈이나 함수를 인자로 넘기는 것과 유사하다. 기술적으로 OOP와 본질적인 차이는 없다. 다만 여기서는 "가상 테이블"이 노골적으로 드러난 구조물일 뿐이다.
다음과 같이, 어떤 구조를 조작하는 여러 연산을 담은 레코드가 있다고 하자. 여기서 a는 사용자가 선택하는 파라미터다.
haskellrecord Arithmetic a = { show : a → String, (+) : a → a → a, (-) : a → a → a, literal : Integer → a }
이 레코드는, 어떤 추상적인 산술 연산을 수행하는 함수에 전달될 수 있다.
haskellArithmetic a → a → a
앞서 본 vtable 예제와 얼마나 닮았는지 눈에 띌 것이다.
함수형 프로그래밍도, 한때 OOP를 밀고 나갔던 그 컨설턴트들에 의해 점령당하는 중이다. 여기서 말하는 "더 나은 방법"은, 함수형 프로그래밍을 영업하는 게 아니라, 종속 타입 이론(dependent type theory)과 논리 기반 프로그래밍을 살짝 홍보하는 정도로만 받아들이길 바란다.
변이(mutation)는 여기서 별로 다루지 않았다. 함수형 프로그래밍에서도 변이 자체가 추가적인 문제를 일으키는 건 아니다. 변이를 문제로 인식하는 이유는, 함수형 프로그래밍이 프로그래머에게 요구하는 정밀함 때문이다. Jean-Yves Girard를 비롯한 여러 수학자들이 이 부분은 오래 전에 처리해 놓았다. 그리고 당장 OOP에서 얻는 것과 똑같은 걸 원한다면, Haskell에서 가변 참조를 쓰는 것만으로도 비슷하게 개판을 만들 수 있다.
타입에 대한 오래된 관념이 하나 있다. Grady Booch의 책을 읽다가, 숫자 3 모양 조각을 어떤 구멍에 끼우려는 그림을 보고 떠올랐다. 조각에는 그것이 무엇을 의미하는지 라벨이 붙어 있고, 구멍은 또 다른 의미를 가진다.
이 관념에 따르면, 타입의 목적은 "닭의 수"와 "닭 한 마리의 가격"을 같은 수로 헷갈리지 않게 하는 것이다. 거의 타입 이론적 관점에 가까운 설명이다. 더 좋은 예는 5달러와 5유로를 섞지 않는 것이다.
이 설명에 따르면, 타입의 목적은 서로 다른 것을 섞어 쓰지 못하게 하는 것이다. 거의 맞지만 약간 틀렸다. 그리고 이런 관점은 다음과 같은 서브타입 계층을 떠올리게 만든다.
textdollar extends money euro extends money
하지만 이 예는 타입이 가진 진짜 힘을 보여 주지 못한다. 통화 단위를 혼동하지 않기 위해 서로 다른 숫자 타입들을 만드는 건, 노력 대비 효과가 너무 작다. 실제로 이렇게까지 하는 사람은 그리 많지 않다.
대신 타입으로 이런 걸 할 수 있다. 어떤 구조가 필요할 때, 실제로 받은 구조가 기대와 정확히 일치하는지 검증하는 것이다. 이 성질을 이용해, 매우 화려한 것들을 요구하고, 그게 실제로 존재함을 증명할 수 있다.
예를 들어, "삼각형의 내각의 합은 180도다"라는 사실을 표현하는 타입을 구성할 수 있다. 이 구조는 그 명제를 증명하는 증명 객체가 된다. 그런 구조를 하나 들고 있다면, 당신이 사용하는 삼각형 모델에서 내각의 합이 180도라는 것을 아는 셈이다.
절차적 언어든 함수형 언어든, 타입을 이용한 어느 정도의 논리적 추론이 가능하다. 차이점이라면, 함수형 언어에서 타입 이론으로 올라가는 계단은 거의 A, B, C 수준으로 간단하다는 것이다.
참조 투명성은 프로그램이 가질 수 있는 잠재적인 속성이다. 참조 투명한 프로그램은, 그 프로그램을 그 값으로 대체해도 행동이 바뀌지 않는다. x와 y가 프로그램이고, x = y라고 쓰면, 이것은 수학에서와 똑같이 "x와 y가 같은 값을 가진다"는 뜻이다. 둘 다 참조 투명하다면, 프로그램의 어디에서든 x를 y로, 또는 그 반대로 바꿔 쓸 수 있다.
프로그래머에게 이것은, 부수 효과(behavior)를 감소 규칙(reduction rule)과 분리하라는 의미로 다가온다. 이는 곧 등식 추론(equational reasoning)을 가능하게 한다. 등식 추론은 우리가 학교에서 배운, 등호를 이용해 표현들 사이를 왔다 갔다 하며 무언가를 증명하는 바로 그 활동이다.
Karsten는 다중 디스패치(multiple dispatch)를 제안했다. 한때는 인기 있던 아이디어다. 아마 누군가가 나쁘다고 지적했을 텐데, 아무도 그 사람 말을 듣지 않은 것 같다. 어쨌든, 언어에 다중 디스패치가 있고, 이런 식으로 쓰인다고 하자.
textadd(int, int) add(float, int) add(int, float) add(float, float) ...
이런 언어가 있다면, 정말 이 기능이 제대로 확장 가능하다는 확신이 없는 한, 가능한 한 멀리 떨어져 있는 편이 좋다. 동적 디스패치는 지나치게 제한적이고, 물리 계산에 필연적으로 등장하는 매개변수 타입과 합쳐지면 금방 복잡해진다. 보통 이런 시스템은 컴퓨터 대수(CAS)에 필요한 요구를 충족하지 못한다. 그리고 필요한 상호운용성도 제공하지 못한다.
문제가 어디 있는지 보려면, 다음 질문만 던져 보라. 만약 int와 float가 서로 의존하지 않는 별도의 모듈이라면, (int,float)와 (float,int)에 대한 add는 어디에 정의해야 하는가? 어디에도 정의하지 말아야 하는가? 어느 한쪽에 정의해야 한다면, 왜 그쪽인가?
최근 OOP 책들은 이런 범주 계층(categorical hierarchy)을 악마화한다. 객체지향의 가장 창피하고도 재밌는 반례들이 이런 예시에서 나오기 때문이다. taxonomy 자체가 나쁜 건 아니다. "사물을 분류하는 유일하게 올바른 방법이 단 하나 뿐"이라고 우기는 순간 문제가 된다. 이건 거의 OOP에서만 발생하는 특이한 문제다.
지도와 투영(projection)을 떠올려 보면 비슷하다. 지구를 평면으로 펼쳐 지도 만드는 여러 방법을 두고 엄청난 노력이 이루어졌다. 어떤 투영은 면적을 보존하고, 어떤 것은 거리나 각도를 보존한다. 대부분의 사람들은, 극소수를 제외하면, 이런 점 때문에 지도를 해석하는 데 큰 어려움을 겪지 않는다.
타입 이론을 올바르게 적용하면, taxonomy를 하나만 고집해야 하는 일은 일어나지 않는다. 어떤 표현이 불편해지면, 동형사상(isomorphism)인 다른 표현으로 옮겨 가면 된다.
보통 동형사상은 다음과 같은 두 함수에 대해 말한다.
textf : a → b g : b → a
g ∘ f가 a에서 항등 함수, f ∘ g가 b에서 항등 함수가 되도록 만들면 두 타입은 서로 동형이다.
동형사상은 서로 동등한 정의들 사이를 옮겨 다닐 수 있게 해 준다. 특정 분류 체계를 "절대적인 표현"으로 고집할 필요가 없다는 뜻이다. 형태를 보존하는 함수를 사이에 끼워 두면, 본질은 그대로 유지된다. 타입 검사기를 통과한 값은 고양이와 같다. 맞는 데가 있으면 얌전히 가서 앉는다.
이 섹션은, 아직도 OOP 패러다임의 수명이 다했다는 말이 믿기지 않는 사람을 위해 적어 둔다. 만약 서브타이핑을 집어치우면, 그에 따른 편의를 잃게 된다. 대신, 그보다 더 편리한 무언가를 얻게 된다.
"타입 = 명제" 대응(Curry–Howard)에 따르면, 타입은 논리 프로그래밍 환경에서 유효한 명제가 된다. 이 아이디어는 이미 Haskell의 타입클래스에서 쓰이고 있다. 다음과 같은 instance 선언은 논리 프로그램으로 해석할 수 있다. 아래는 Haskell의 instance 선언과, 그것과 가장 비슷한 Prolog 프로그램이다.
haskellinstance Show String instance Show Int instance (Show a, Show b) => Show (a,b) instance Show a => Show [a]
prologshow(string). show(int). show(pair(A,B)) :- show(A), show(B). show(list(A)) :- show(A).
컴파일러가 (Show [(Int, String)]) 같은 타입 제약을 만나면, GHC 컴파일러는 일종의 증명 탐색을 수행한다고 볼 수 있다. 여기서 반환되는 "증명"은 그 제약을 충족시키는 완전히 구성된 instance다. 이런 시스템이 잘 돌아가려면, 추론이 만들어 내는 결과들 중 어느 것이든 서로 동등하게 허용 가능해야 한다. 이를 위해 Haskell에서는, 이 시스템이 항상 유일한 결과를 내도록 기능을 제한해 두었다. 어쨌든 여기서 보이는 것은, "명백한" 부분을 컴파일러가 대신 작성해 주는 모습이다.
Prolog와 Haskell 타입 검사기 사이의 유사성은 새삼스러운 관찰이 아니다. Thomas Hallgren은 20년 전, "Fun with Functional Dependencies" [pdf]라는 논문을 썼다. 이 논문은 Haskell의 타입클래스 시스템이, 컴파일 타임에 결정 가능한 계산을 표현하는 데 어떻게 사용될 수 있는지를 보여준다. 그 중 가장 복잡한 예는 정적 삽입 정렬(insertion sort)의 구현이다.
이런 기능들을 절차적/객체지향 환경으로 그대로 "포팅"하기는 쉽지 않다. 타입 이론을 더 엄격하게 적용해 얻는 일관성에 크게 의존하고 있기 때문이다.
OOP가 왜 이렇게 우리를 괴롭히는지, 왜 주기적으로 이런 글이 나오는지에 대한 잠재적인 수학적 이유가 있다. 인기 있는 언어들이 열어 둔 규칙의 여지와 관련된다.
OOP 언어가 타입 시스템을 동반하는 경우, 일부 멍청한 일은 못 하게 막아 준다. 하지만 여전히 한참이나 멍청한 일은 허용한다. 이렇게 작성된 코드들은, 다른 코드들과 조합되다 보면 결국 깨지게 마련이다. 그러면 프로그래머는 이에 대처하기 위해, 가능한 한 엄격한 인터페이스를 쓰게 된다.
Java와 C#은 이런 경향을 뒷받침한다. 추상적인 타입으로 선언하는 것은 불편하게 만들어 놓고, int나 float 같은, 머신 구현과 상당히 밀접한 타입들을 쓰는 것을 더 편하게 만든다. 32비트 IEEE754 부동소수점 수는 우리가 실수(real number)에 기대하는 많은 대수 법칙을 만족하지 않는다. 정수 타입도 보통은 범위가 제한된 머신 정수이고, 일반적인 정수 연산이 아니라 모듈러 산술처럼 동작한다. 이런 타입을 선택하는 순간, 많은 다른 표현 가능성들이 조기에 차단된다.
함수형 프로그래밍에서는, 어떤 값이 시스템을 통과하는 동안 손대지 않고 유지되길 원한다면 그냥 a라고 말하면 된다. 이건 최대한 추상적인 표현이고, 아주 적은 노력으로도 같은 프로그램의 다양한 변형을 만들어 낼 수 있게 해 준다.