오늘날 유행처럼 번진 OOP 비판을 계기로, 객체지향 프로그래밍을 구성하는 여러 아이디어를 하나씩 짚어 보고 장단점을 살펴본다.
생각해 보면 요즘은 OOP를 깎아내리는 분위기가 유행처럼 번진 것 같다. Lobsters에서 OOP 관련 글 두 편이 연달아 올라오는 것을 보고 이 글을 써야겠다고 마음먹었다. 나는 OOP를 옹호하거나 공격하고 싶은 마음은 없지만, 내 나름의 생각을 보태 조금 더 미묘한 관점을 제시하고 싶다.
업계와 학계는 “객체지향(object-oriented)”이라는 용어를 너무도 다양한 의미로 사용해 왔다. OOP를 둘러싼 논의가 생산적이지 못한 가장 큰 이유 중 하나는, OOP가 무엇인지에 대한 합의가 없기 때문이다.
객체지향 프로그래밍이란 무엇인가? 위키백과는 이를 “객체(object)의 개념에 기반한 프로그래밍 패러다임”이라고 정의한다. 이 정의는 “객체”가 무엇인지 다시 정의해야 할 뿐 아니라, 업계에서 이 용어가 사용되는 상이한 방식들을 포괄하지 못한다는 점에서 만족스럽지 못하다. 앨런 케이의 OOP 비전도 있다. 하지만 오늘날 대부분의 사람들이 이 용어를 사용하는 방식은 이미 그와 꽤 멀어졌고, 나는 “진짜” 의미를 주장하며 본질주의나 어원 오류에 빠지고 싶지는 않다.
대신, 나는 OOP를 서로 연관된 아이디어들의 잡다한 묶음으로 보고, 각각을 따로 살펴보는 편이 낫다고 생각한다. 아래에서는 OOP와 관련된 몇 가지 아이디어들을 훑어 보면서 (지극히 주관적인 관점에서) 그 장단점을 언급해 보겠다.
객체지향 프로그래밍은 프로그램들을, 몇몇 클래스의 인스턴스를 나타내는 객체들의 협력적 집합으로 조직하는 구현 방법이며, 이 클래스들은 모두 상속 관계로 이어진 클래스 계층의 일원이다. — Grady Booch
클래스는 메서드 문법, 정보 은닉, 상속에 대한 지원을 더해 “struct”나 “record”의 아이디어를 확장한 것이다. 이들 개별 기능에 대해서는 뒤에서 따로 이야기하겠다.
클래스는 객체를 만들기 위한 청사진으로 볼 수도 있다. 다만 그것만이 유일한 방법은 아니고, 프로토타입(prototype) 이라는 대안도 있다. 프로토타입은 Self에서 처음 도입되었고, 가장 유명한 사례는 자바스크립트다. 개인적으로는 프로토타입은 클래스에 비해 이해하기가 더 어렵다고 느낀다. 실제로 자바스크립트조차, ES6 클래스 문법으로 초심자에게 프로토타입 사용을 가리려 한다.
일본어에는 문장 연결(sentece chaining)이 있는데, 이것은 루비의 메서드 체이닝과 비슷합니다. — 마츠모토 유키히로
메서드 문법은 비교적 논란이 적은 OOP 기능 중 하나다. 이는 특정 “대상(subject)”에 대한 연산을 수행하는 흔한 프로그래밍 패턴을 포착한다. 메서드가 없는 언어에서도, 관련 데이터를 첫 번째(또는 커링을 지원하는 언어에서는 마지막) 인자로 받는 함수가 사실상 메서드 역할을 하는 경우가 흔하다.
문법적으로는 메서드 정의와 메서드 호출이 있다. 메서드를 지원하는 언어는 보통 둘 다 제공한다. (다만, 함수형 언어의 “파이프 연산자”를 일종의 메서드 호출로 본다면 예외가 있겠지만.)
메서드 호출 문법은 IDE의 자동 완성을 돕고, 메서드 체이닝은 중첩된 함수 호출보다 더 인체공학적(ergonomic)인 경우가 많다(함수형 언어에서 파이프 연산자가 그러하듯이).
하지만 메서드 문법에도 논쟁적인 측면이 있다. 첫째, 많은 언어에서 메서드는 클래스 밖에서 정의할 수 없다. 이 때문에 함수에 비해 힘의 불균형이 생긴다. 예외적으로 러스트(메서드는 항상 struct 바깥에서 정의된다), 스칼라, 코틀린, C# (확장 메서드) 같은 언어는 좀 다르다.
둘째, 많은 언어에서 this나 self는 묵시적이다. 이는 코드를 더 간결하게 만들어 주지만, 혼동을 부르고 실수로 이름이 가려지는(name shadowing) 위험을 키우기도 한다. 암시적 this의 또 다른 단점은 항상 포인터로 전달되며, 그 타입을 바꿀 수 없다는 점이다. 즉, 복사본을 넘길 수 없고, 이 간접 참조가 때로는 성능 문제로 이어질 수 있다. 더 중요한 점은 this의 타입이 고정되어 있기 때문에, 서로 다른 this 타입을 받는 제네릭 함수를 작성하기 힘들다는 것이다. 파이썬과 러스트는 이 부분을 처음부터 제대로 설계했고, C++도 deducing this를 통해 C++23에서야 이 문제를 고쳤다.
셋째, “자유 함수(free function)”와 메서드가 둘 다 있는 언어에서는, 같은 일을 하는 두 가지 상호 호환되지 않는 방식이 생긴다. 이는 제네릭 코드에서 문제를 일으킬 수 있다. 러스트는 메서드 이름을 완전히 한정하여(function 형태로) 호출할 수 있도록 함으로써 이 문제를 해결한다.
넷째, 대부분의 언어에서 점 표기법(dot notation)은 인스턴스 변수 접근과 메서드 호출에 모두 쓰인다. 이는 메서드와 객체를 더 균일하게 보이게 하려는 의도적인 선택이다. 메서드가 인스턴스 변수 인 동적 타입 언어에서는, 이는 자연스럽고 사실상 선택의 여지도 없다. 반면 C++이나 자바 같은 언어에서는 혼동과 그림자(shadowing) 문제를 일으킬 수 있다.
그 인터페이스 혹은 정의는 내부 동작에 대해 가능한 한 적은 정보를 드러내도록 선택되었다. — [Parnas, 1972b]
스몰토크에서는 모든 인스턴스 변수를 객체 외부에서 직접 접근할 수 없고, 모든 메서드는 외부에 노출된다. 이후의 현대 OOP 언어들은 클래스 수준의 private 같은 접근 지정자를 통해 정보 은닉을 지원한다. OOP가 아닌 언어들조차, 모듈 시스템, 불투명 타입(opaque type), 혹은 심지어 C의 헤더/소스 분리처럼 어떤 형태로든 정보 은닉을 지원한다.
정보 은닉은 불변식(invariant)이 깨지는 것을 막는 좋은 방법이다. 또한 자주 바뀌는 구현 세부 사항을 안정적인 인터페이스와 분리하는 데도 유용하다.
하지만 지나치게 공격적인 정보 은닉은 불필요한 보일러플레이트나 추상화 역전(abstraction inversion)을 야기할 수 있다. 또 다른 비판은 함수형 프로그래머들에게서 온다. 그들은, 데이터가 불변(immutable) 이라면 불변식을 유지할 필요가 없기 때문에 정보 은닉도 별로 필요 없다고 주장한다. 어떤 의미에서 OOP는, 불변식으로 유지·관리해야 하는 가변 객체를 작성하도록 사람들을 부추기기도 한다.
정보 은닉은 또한 “스스로를 다룰 줄 아는” 작고 독립적인 객체를 만들도록 장려하는데, 이는 곧 캡슐화(encapsulation) 주제로 이어진다.
할 수만 있다면, 그 동작 전체를 그 동작이 도움을 주는 클래스(안)로 옮겨라. 결국 OOP란 객체가 스스로를 돌보도록 하는 것이다. — Bob Nystrom, Game Programming Patterns
캡슐화는 정보 은닉과 종종 혼동되지만, 서로 다른 개념이다. 캡슐화는 데이터를 그것을 조작하는 함수와 함께 묶는 것을 가리킨다. OOP 언어는 클래스와 메서드 문법으로 캡슐화를 직접 지원하지만, 다른 접근도 있다(예: OCaml의 모듈 시스템).
캡슐화를 위한 가장 흔한 구성 요소는 객체이지만, 그것만이 유일한 메커니즘은 아니다. 많은 현대 언어는 클로저를 지원하며(실제로 클로저와 객체는 서로를 모방할 수 있다), ML 계열에서 볼 수 있는 모듈 같은 덜 알려진 방식도 있다.
데이터 지향 설계(data-oriented design)는 데이터와 기능을 함께 묶는다는 생각에 대해 많은 이야기를 한다. 객체가 많을 때, 각각을 개별적으로 처리하는 것보다, 배치(batch)로 묶어 처리하는 편이 훨씬 효율적일 때가 많다. 작은 객체들이 서로 다른 동작을 갖도록 만들면, 데이터 지역성이 나빠지고, 간접 참조가 늘어나며, 병렬화 기회도 줄어들 수 있다. 물론 데이터 지향 설계 지지자들은 캡슐화를 전면 부정하지는 않는다. 다만, 도메인 모델의 개념적 구조가 아니라 실제 코드 사용 방식에 맞춰 더 거친(grain이 큰) 단위로 조직하자고 권한다.
“복잡한 시스템의 어떤 부분도, 다른 어떤 부분의 내부 세부 사항에 의존해서는 안 된다.” — Daniel Ingalls, “The Smalltalk-76 Programming System Design and Implementation”
인터페이스와 구현의 분리는 정보 은닉, 캡슐화, 추상 데이터 타입(ADT)과 깊이 연관된 오래된 아이디어다. 어떤 의미에서 C의 헤더 파일조차 인터페이스로 볼 수 있지만, OOP에서 “인터페이스”라는 말을 쓸 때는 대개 다형성을 지원하는 특정 언어 구성 요소 집합(보통 상속으로 구현됨)을 가리킨다. 보통 인터페이스는 데이터를 포함할 수 없고, 더 제한적인 언어(예: 초기 자바 버전)에서는 메서드 구현조차 포함할 수 없다. 같은 아이디어는 비 OOP 언어에서도 흔하다. Haskell의 타입 클래스, Rust의 trait, Go의 interface 모두 구현과 독립된 추상 연산 집합을 명세하는 역할을 한다.
인터페이스는 종종, 풀 기능의 클래스 상속에 비해 더 단순하고 규율 잡힌 대안으로 여겨진다. 단일 목적의 기능이며, 다중 상속에서 나타나는 다이아몬드 문제도 겪지 않는다.
인터페이스는 매개변수 다형성(parametric polymorphism)과 결합될 때 특히 유용한데, 타입 매개변수가 지원해야 하는 연산들을 제한할 수 있기 때문이다. 동적 타입 언어(및 C++/D 템플릿)는 덕 타이핑(duck typing)을 통해 비슷한 기능을 제공하지만, 덕 타이핑이 있는 언어들조차 나중에는 제약을 더 명시적으로 표현하기 위해 인터페이스 비슷한 구문을 도입한다(예: C++ concepts, TypeScript interface).
OOP 언어에서 구현된 인터페이스는 종종 런타임 비용을 수반하지만, 반드시 그런 것은 아니다. 예를 들어 C++ concepts는 컴파일 타임에만 작동하는 예이며, Rust trait 역시 dyn을 통해 선택적으로 런타임 다형성을 지원한다.
내게 OOP란 오직 메시징, 지역적인 상태-프로세스 보존과 보호 및 숨김, 그리고 가능한 모든 것의 극단적인 늦은 바인딩을 의미한다. — Alan Kay
늦은 바인딩은 메서드나 멤버의 조회를 런타임까지 미루는 것을 말한다. 동적 타입 언어 대부분에서의 기본 모드이며, 이들 언어에서는 메서드 호출이 종종 해시 테이블 조회로 구현된다. 다만, 동적 로딩이나 함수 포인터 같은 다른 수단으로도 늦은 바인딩을 구현할 수 있다.
늦은 바인딩의 핵심은, 프로그램이 실행 중인 동안에도 동작을 바꿀 수 있다는 점이다. 이를 통해 각종 핫 리로딩(hot reloading)과 몽키 패칭(monkey patching) 워크플로가 가능해진다.
늦은 바인딩의 단점은 무시할 수 없는 성능 비용이다. 게다가, 불변식을 깨뜨리거나 인터페이스가 맞지 않는 상황을 초래하는 위험한 도구가 되기도 한다. 그 가변적인 특성 때문에 더 미묘한 문제도 생길 수 있는데, 예컨대 파이썬의 “late binding closures” 함정이 그런 예다.
C++에서의 한 프로그래밍 패러다임으로, 가상 함수를 사용한 런타임 함수 디스패치를 기반으로 한 다형성을 사용한다. — Back to Basics: Object-Oriented Programming - Jon Kalb - CppCon 2019
늦은 바인딩과 관련된 개념으로, 다형적 연산의 구현을 런타임에 선택하는 동적 디스패치가 있다. 두 개념은 서로 겹치지만, 동적 디스패치는 이름 조회보다는, 여러 알려진 다형 연산 중에서 구현을 선택하는 측면에 더 초점을 둔다.
동적 타입 언어에서는 모든 것이 늦게 바인딩되므로 동적 디스패치가 기본이다. 정적 타입 언어에서는 보통 가상 함수 테이블(vtable)을 통해 구현되며, 내부적으로는 대략 다음과 같이 생겼다:
cppstruct VTable { // 기반 클래스를 파괴하는 함수 포인터 void (*destroy)(Base&); // 한 메서드 구현에 대한 함수 포인터 void (*foo)(); // 다른 메서드 구현에 대한 함수 포인터 int (*bar)(int); }; struct BaseClass { VTable* vtable; };
이런 언어들은 또한 vtable이 해당 타입에 대해 유효한 연산들을 담고 있다는 것을 컴파일 타임에 보장해 준다.
동적 디스패치는 상속과 분리될 수도 있다. 예컨대 수동으로 vtable을 구현한다든지(C++의 std::function 같은 “타입 소거(type-erased) 타입”) 혹은 interface/trait/typeclass 류의 구문을 이용하는 방식이 있다. 상속 없이 동적 디스패치만 사용하면 보통은 “OOP”라고 부르지 않는다.
또 하나 짚어 둘 점은, vtable 포인터가 객체 내부에 바로 들어갈 수도 있고(C++), “두꺼운 포인터(fat pointer)” 안에 포함될 수도 있다는 점이다(Go, Rust 등).
동적 디스패치에 대한 불평은 보통 성능과 관련이 있다. 가상 함수 호출 자체는 꽤 빠를 수 있지만, 인라이닝 기회를 줄이고, 캐시 미스 및 분기 예측 실패 가능성을 키운다.
클래스 계층과 가상 함수를 사용하여, 다양한 타입의 객체를 잘 정의된 인터페이스를 통해 다루고, 파생을 통해 프로그램을 점진적으로 확장할 수 있게 하는 프로그래밍 방식. — Bjarne Stroustrup
상속은 Simula 67까지 거슬러 올라가는 긴 역사를 지니며, 아마 OOP의 가장 상징적인 기능일 것이다. “객체지향 언어”로 마케팅되는 거의 모든 언어가 상속을 포함하고, OOP를 피하는 언어는 보통 상속을 생략한다.
상속은 엄청나게 편리 하다. 많은 경우, 다른 대안을 쓰면 보일러플레이트가 훨씬 많아진다.
하지만 상속은 매우 비정교적(non-orthogonal)인 기능이다. 하나의 메커니즘으로, 동적 디스패치, 서브타이핑 다형성, 인터페이스/구현 분리, 코드 재사용을 모두 가능하게 해 버린다. 그만큼 유연하지만, 이 유연함 때문에 남용하기 쉽다. 이런 이유로, 요즘 일부 언어들은 상속 대신 더 제한적인 대안들을 채택한다.
상속에는 다른 문제들도 있다. 첫째, 상속을 사용하면 거의 항상 동적 디스패치와 힙 할당에 따른 성능 비용을 치르게 된다. C++ 같은 일부 언어에서는 동적 디스패치와 힙 할당 없이 상속을 사용할 수 있고, CRTP를 통한 코드 재사용처럼 몇 가지 합당한 용례도 있다. 하지만 상속의 대부분 용도는 런타임 다형성을 위한 것이며, 이는 곧 동적 디스패치에 의존한다.
둘째, 상속은 서브타이핑을 “sound하지 않은(불완전한)” 방식으로 구현하며, 프로그래머가 리스코프 치환 원칙을 수동으로 지켜야 한다.
마지막으로, 상속 계층은 경직되어 있다. 대각선 문제(diagonal problem) 같은 이슈에 시달리며, 이런 유연성 부족은 사람들이 상속보다 컴포지션을 선호하는 주요 이유 중 하나다. Game Programming Patterns의 컴포넌트 패턴 챕터는 좋은 예를 보여 준다.
타입 의 각 객체 에 대해, 타입 의 어떤 객체 가 있고, 로 정의된 모든 프로그램 에서 를 대신 사용해도 의 동작이 변하지 않는다면, 는 의 서브타입이다. — Barbara Liskov, “Data Abstraction and Hierarchy”
서브타이핑은 두 타입 사이의 “~는 ~이다(is a)” 관계를 기술한다. 리스코프 치환 원칙은 안전한 서브타이핑 관계가 만족해야 하는 성질을 정의한다.
OOP 언어는 종종 상속을 통해 서브타이핑을 지원하지만, 상속이 항상 서브타이핑을 잘 모델하는 것도 아니고, 서브타이핑의 유일한 형태도 아니다. 비 OOP 언어의 각종 interface/trait 구문도 서브타이핑을 지원하는 경우가 많다. 또한, 서브타이핑에는 명시적으로 관계를 선언하는 명목적 서브타이핑(nominal subtyping) 뿐 아니라, 한 타입이 다른 타입의 모든 기능을 포함할 때 묵시적으로 성립하는 구조적 서브타이핑(structural subtyping) 도 있다. OCaml(객체와 다형 변이체)와 TypeScript interface는 구조적 서브타이핑의 좋은 예다. 서브타이핑은 러스트의 수명(lifetime)이나, TypeScript에서 non-nullable 타입이 nullable 타입으로 자동 변환되는 등의 사소한 곳에도 숨어 있다.
서브타이핑과 연관된 개념으로 변성(variance)이 있다(클래스 불변식과는 관련 없다). 변성은 매개변수 다형성과 서브타이핑을 연결해 준다. 이 주제를 제대로 설명하려면 아마 글 한 편이 더 필요하니 여기서 자세히 다루지는 않겠다. 변성은 큰 인체공학적 장점이 있다(예: C++ 포인터가 공변(covariant)이 아니었다면 다형적 용도로 거의 쓸 수 없었을 것이다). 하지만 이해하기 어렵고 오류를 유발하기 쉬워서, 대부분의 언어는 제한적인, 하드코딩된 형태만 지원한다. 특히, 가변 데이터 타입은 보통 불변(invariant)이어야 하고, Java/C#의 공변 배열은 이 원칙이 잘못 적용된 대표 사례다. 프로그래머가 변성을 명시적으로 제어할 수 있게 하는 언어는 Scala와 Kotlin 정도로 많지 않다.
서브타이핑에 의한 타입 변환은 종종 암시적이다. 암시적 변환은 평판이 좋지 못하다. 하지만 서브타이핑을 통한 암시적 변환은 인체공학 측면에서 좋고, 아마 가장 놀랄 일이 적은 암시적 변환일 것이다. 또 다른 관점에서 보면, 서브타이핑은 암시적 변환의 쌍대(dual)로 볼 수도 있다. 암시적 변환으로 서브타이핑 관계를 “가짜로” 만드는 것도 가능하다. 예컨대 C++ 템플릿 타입은 불변(invariant)이지만, std::unique_ptr는 std::unique_ptr<Derived>에서 std::unique_ptr<Base>로의 암시적 변환을 제공함으로써 공변성을 달성한다. 이 아이디어를 더 다룬 글로 Does Go Have Subtyping?이 있다.
언어 설계자들이 서브타이핑을 피하려는 또 하나의 이유는 구현 복잡성이다. 양방향 타입 추론과 서브타이핑을 통합하는 일은 악명 높게 어렵다. Stephen Dolan의 2016년 논문 Algebraic Subtyping은 이 문제를 푸는 데 꽤 진전을 보인다.
나는 객체를, 메시지만을 통해 통신할 수 있는 생물학적 세포나 네트워크 상의 개별 컴퓨터처럼 생각했다. — Alan Kay
메시지 패싱은 서로에게 “메시지”를 보내는 객체들로 실행을 구성하는 것을 의미한다. 이는 앨런 케이의 OOP 비전에서 핵심적인 주제지만, 정의 자체는 꽤 모호할 수 있다. 중요한 점은, 메시지 이름이 늦게 바인딩되고, 이 메시지들의 구조가 컴파일 타임에 고정되어 있을 필요는 없다는 것이다.
많은 초기 객체지향 개념은 분산 및 시뮬레이션 시스템에서 영향을 받았고, 이들 환경에서는 메시지 패싱이 자연스럽다. 하지만 대부분이 단일 스레드 코드에서 일하는 시대가 되자, C++과 자바 같은 언어에서는 메시지 패싱이라는 아이디어가 점차 잊히게 되었다. 메서드 문법은 원래의 메시지 패싱 아이디어에 비해 제한적인 장점만 제공한다(비야네 스트로스트룹은 Simula에서 온 메시지 패싱 개념을 알고 있었지만, 이를 어떻게 빠르게 구현할지에 현실적인 제약이 있었다). 실제 메시지 패싱은 여전히 존재했지만, 프로세스 간 통신이나 고도로 이벤트 기반인 일부 시스템 같이 제한된 영역에 머물렀다.
메시지 패싱은 동시성 프로그래밍에서, 역설적이게도 Erlang과 Golang 같은 비 OOP 언어를 통해 르네상스를 맞았다. 액터(actor)와 채널(channel) 같은 구성 요소가 그 예다. 이런 공유 없음(shared-nothing) 동시성은 데이터 레이스와 경쟁 상태(race condition) 버그의 전체 계열을 제거해 준다. 감독(supervision)과 결합하면, 액터는 하나의 액터 장애가 전체 프로그램에 영향을 미치지 않게 하는 내결함성(fault tolerance) 또한 제공한다.
일반적으로, 경험칙은 이렇다: 열린 재귀가 크게 도움이 되는 상황이라면, 클래스와 객체를 사용하라. — Real World OCaml
유명한 책 Types and Programming Languages에서 유래한 열린 재귀(open recursion) 는, 이 글에서 아마 가장 덜 알려지고, 덜 이해된 용어일 것이다. 그럼에도, 이는 객체지향 시스템의 익숙한 특성을 가리킬 뿐이다. 즉, 상속 계층의 서로 다른 클래스에서 정의된 메서드들이, 서로를 호출할 수 있다는 것이다.
용어 자체는 조금 오해를 부를 수 있는데, 실제로 재귀 함수 호출이 전혀 없을 수도 있기 때문이다. 여기서 “재귀(recursion)”란 “상호 재귀(mutually recursive)”를 뜻한다. “열린(open)”은 보통 상속을 통해 “확장에 열려 있음(open to extension)”을 의미한다.
예시를 보면 가장 이해하기 쉽다:
cppstruct Animal { void print_name() const { // 여기에서 `name`을 호출한다. 하지만 name은 Animal에 정의되어 있지 않다. std::print("{}\n", name()); } virtual std::string name() const = 0; }; struct Cat: Animal { std::string name() const override { return "Kitty"; } }; int main() { Cat cat; // Cat에는 정의되어 있지 않은 print_name을 호출한다. cat.print_name(); }
OOP에 어느 정도 익숙한 사람이라면, 아마 이름은 몰라도 열린 재귀의 존재 자체는 당연하게 받아들일 것이다. 하지만 모든 언어 구성 요소가 이런 특성을 갖는 것은 아니다. 예를 들어, 많은 언어에서 함수는 기본적으로 상호 재귀가 아니다:
cpp// C++에서는 `name`이 정의되어 있지 않으므로 컴파일되지 않는다. void print_name(const Animal& animal) { return name(animal); } std::string name(const Cat& cat) { return "Kitty"; }
이제, 늦게 바인딩되는 함수가 있는 언어에서는, 같은 모듈에 있는 함수들이 항상 서로를 호출할 수 있다(예: 파이썬, 자바스크립트). 다른 언어들 중에는 함수가 기본적으로 상호 재귀인 언어도 있고(예: 러스트), 선행 선언(forward declaration)을 제공하는 언어(C)나, letrec 구문을 가진 언어(Scheme, ML 계열)도 있다. 이런 장치들은 “재귀” 부분은 해결하지만, 여전히 “열린” 부분은 해결하지 못한다:
cppstd::string name(const Cat& cat); void print_name(const Animal& animal) { // Animal을 Cat으로 다운캐스트할 수 없으므로 이것도 컴파일되지 않는다. return name(animal); } std::string name(const Cat& cat) { return "Kitty"; }
콜백을 사용해서 이 문제를 고쳐 보자:
cppstruct Animal { std::function<std::string()> get_name; void print_name() const { std::print("{}\n", get_name()); } }; Animal make_cat() { return Animal{ .get_name = []() { return "Kitty"; }, }; } int main() { Animal cat = make_cat(); cat.print_name(); }
짜잔, 프로토타입 스타일 디스패치를 방금 다시 구현해 버렸다!
아무튼, 위의 짧은 예제들을 통해 보여 주고 싶었던 것은, 열린 재귀는 OOP가 공짜로 제공하는 성질이지만, 언어 차원의 지원 없이 이를 재현하는 것은 까다로울 수 있다는 점이다. 열린 재귀는 객체의 상호 의존적인 부분들을 분리해서 정의할 수 있게 해 주며, 데코레이터 패턴의 전체 아이디어처럼, 많은 사례가 이 특성에 의존한다.
아마 OOP에 대한 더 흔한 불만은, 특정 언어 기능 자체보다는, 그것이 장려하는 프로그래밍 스타일에 대한 것일 것이다. 많은 관행들이 보편적인 모범 사례로 가르쳐지며, 때로는 그 이유도 설명되지만, 단점은 종종 생략된다. 내 머릿속에 떠오르는 예시 몇 가지를 표로 정리해 보면 다음과 같다.
| 관행 | 장점 | 단점 |
|---|---|---|
| 태그된 유니온/if/switch/패턴 매칭보다 다형성을 선호하기 | 확장에 열려 있고, 새로운 경우를 추가하기 쉽다. | 성능 저하; 관련 동작들이 여러 곳에 흩어짐; 전체 제어 흐름을 한눈에 보기 어려움 |
| 모든 데이터 멤버를 private으로 만들기 | 클래스 불변식 보호 | 보일러플레이트 증가; 불변식이 없는데도 데이터를 숨기는 경우가 많음; 프로퍼티 문법이 없는 언어에서는 getter/setter 쌍이 직접 접근보다 나쁨 |
| 중앙 “매니저”보다 작고 “스스로 관리하는” 객체를 선호하기 | 불변식 위반 가능성이 줄고, 코드 구조가 더 깔끔해짐 | 나쁜 데이터 지역성, 병렬성 기회 상실, 공통 데이터에 대한 역포인터(back pointer) 중복 |
| 수정을 피하고 확장만 하려 하기 | 새로운 기능이 기존 기능을 깨뜨리지 않게 함. API 변경 방지 | 자신이 사용처를 모두 소유한 비공개 모듈은 “닫을” 이유가 없음; 불필요한 복잡성과 상속 체인을 야기; 잘못 설계된 인터페이스가 고쳐지지 않음; 추상화 역전을 유발할 수 있음 |
| 구체 구현보다 추상을 선호하기 | 시스템을 더 쉽게 교체하고 테스트할 수 있음 | 과도한 사용은 가독성과 디버깅 가능성을 희생; 추가 간접 참조로 인한 성능 비용 |
이 글은 이미 충분히 길어졌으므로 더 깊이 들어가지는 않겠다. 내가 적어 놓은 “장점과 단점”에 동의하지 않아도 괜찮다. 내가 전달하고 싶은 요지는, 거의 모든 이런 관행에는 트레이드오프가 있다는 것이다.
여기까지 읽어 내려온 당신에게 축하를 보낸다! 더 이야기하고 싶은 주제들, 예를 들어 RAII나 디자인 패턴 같은 것도 있지만, 이 글은 이미 충분히 길다. 이 부분들은 여러분 스스로 더 탐구해 보기를 남겨 두겠다.