OCaml에서 가드된 메서드(Guarded methods)
요약
본 문서는 OCaml에서 특정 메서드에만 제약 조건(constraints)을 붙일 수 있는 '가드된 메서드' 개념을 다룹니다. 이는 타입 동등성 증명(type equality witness)을 사용하여 구현하는 방법을 탐구합니다. 클래스 전체가 아닌 개별 메서드 수준에서 제약을 걸어 객체 지향의 메시지 전송 체계를 유지하면서도, 확장 메서드의 유연성을 확보하는 것이 목표입니다.
핵심 포인트
- 가드된 메서드는 특정 메서드에만 타입 제약 조건을 적용할 수 있게 합니다.
- OCaml에서는 '타입 동등성 증명'을 이용해 이 개념을 인코딩합니다.
- 확장 메서드가 클래스 외부 정의의 문제를 해결하지만, 가드된 메서드는 이를 내부적으로 처리하려 합니다.
**가드된 메서드(Guarded methods)**는 수신자(self)에 특정 메서드에 대해서만 제약 조건(constraints)을 붙일 수 있게 합니다. 따라서 이 메서드는 수신자가 해당 제약 조건을 만족할 경우에만 호출될 수 있습니다 (이러한 가드). OCaml은 문법적으로 이러한 종류의 메서드를 직접 정의하는 것을 허용하지 않습니다. 본 문서에서는 **타입 동등성 증명(type equality witness)**을 사용하여 이를 인코딩하는 방법을 살펴보겠습니다.
문제 제시
언어(Java나 OCaml처럼 프로그램 실행 전에 타입 검사가 이루어지는 언어)가 매개변수 다형성(parametric polymorphism) (Java의 generics)을 도입할 때, 때로는 타입 변수를 제약하는 것이 가능합니다. 예를 들면 다음과 같습니다:
class MyClass extends S {...}
우리는 MyClass가 타입 변수 T가 S의 하위 타입(subtype)이라고 가정하여 MyClass를 제네릭하게 만듭니다. 문제는 이 제약 조건이 클래스 전체에 적용된다는 것입니다. 하지만 때로는 제약 조건이 특정 메서드에만 적용되기를 원할 때가 있습니다. 예를 들어, 리스트를 설명하는 MyList라는 클래스가 있다고 가정해 봅시다:
class MyList extends ArrayList<A> {public int length {return this.size();}}
리스트 [[1, 2, 3], [4, 5]]에 대해 리스트 [1, 2, 3, 4, 5]를 생성하는 flatten 메서드를 어떻게 정의할 수 있을까요? 만약 우리가 클래스 레벨에서 제약 조건을 설정하면, 우리의 리스트는
이 접근 방식은 작동하며 특별한 의식(ceremony)을 요구하지 않습니다. 하지만 개발자가 어떤 메서드가 클래스 본문에 있고 어떤 것이 정적 컨텍스트에 있는지 추적하도록 강제합니다. 게다가, 이는 인스턴스로 메시지를 보내는 체계적인 접근 방식을 깨뜨립니다 (종종 객체 지향 프로그래밍의 주요 장점으로 제시됩니다).
확장 메서드(Extension methods)
Kotlin(및 C#와 같은 다른 언어)은 확장 메서드를 제공하는데, 이는 이미 존재하는 클래스에 동작을 추가하는 데 매우 유용할 수 있는 클래스 확장을 허용하는 것 외에도 *수신자(receiver)*를 정의하는 데 더 많은 유연성을 제공합니다. 예를 들어, 다음과 같이 flatten을 작성할 수 있습니다 (Kotlin에서):
class MyList<A> : ArrayList<A> { ... }
fun <A> MyList<MyList<A>>.flatten() = ...
이 해결책은 거의 완벽해 보이지만, 여전히 메서드가 클래스 외부에 정의되어야 하므로, 확장으로부터 접근 가능하도록 클래스의 특정 멤버를 public으로 만들어야 할 수도 있습니다 (잠재적인 *누출된 추상화(leaky abstractions)*처럼 보입니다). 하지만 이는 수신자에 대한 더 세밀한 자격 부여를 허용하면서도 체계적인 메시지 전송 접근 방식을 유지합니다.
가드된 메서드(Guarded methods)
마지막 접근 방식은 아마도 가장 이념적일 것입니다. 왜냐하면 메서드 정의를 클래스 내부에 유지하기 때문입니다. 이는 어떤 탈출한 추상화(escaped abstractions)도 강제하지 않습니다. 이것이 바로 가드된 메서드의 아이디어로, 메서드 정의 수준에서 제네릭 매개변수에 대한 제약 조건을 추가할 수 있는 능력입니다. 가상의 구문에서는 (이 코드는 문법적으로 유효하지는 않지만 컴파일되므로 의도한 효과를 내지는 못합니다):
class MyList<A> : ArrayList<A>() {
fun length() = size
fun <B> MyList<MyList<B>>.flatten() = ...
이것이 고전적인 확장 메서드(extension methods)와 크게 다르지 않아 보일지라도 (클래스 본문 내부에서 문법으로 입증되듯이), 이 방식은 앞서 제기된 모든 문제를 해결합니다:
- 일반적인 메서드보다 수신자(receiver)를 더 정확하게 특성화할 수 있습니다. - 일반적인 메시지 전송을 방해하지 않습니다.
- 여전히 사용 가능한 멤버들로부터 혜택을 받습니다 (따라서 표현(representations)에서 벗어나지 않습니다).
가드된 메서드(guarded methods)는 필요해 보이지만, 안타깝게도 이들을 허용하는 주류 언어에 대해서는 아는 것이 없습니다. 정말 슬픈 일입니다. 다행히 OCaml에서는 이를 인코딩할 수 있습니다.
OOP/FP 대칭성: 이론과 실제
가드된 메서드는 인기 있는 프로그래밍 언어에서 매우 드물기 때문에, 저는 Gabriel Scherer의 발표 자료 "객체 지향/함수형 프로그래밍의 대칭성: 이론과 실제(The Object-Oriented/Functional-Programming symmetry: theory and practice)"를 읽다가 그 존재를 상당히 최근에 발견했습니다.
저는 이 발표를 추천합니다. 이 발표는 정적 타입 함수형 프로그래밍 도구와 객체 지향 프로그래밍 도구 사이의 대칭성을 보여줍니다. 이러한 대칭성은 여러 번 관찰되고 연구되었음에도 불구하고, 이 발표 자료는 포괄적이고 접근성이 높습니다 (그리고 비교적 편견이 없어 두 접근 방식의 장단점을 논합니다). 아쉽게도 발표 중에는 다루지 않았지만 (발표자에게 시간은 종종 적), 슬라이드에 가드된 메서드에 대한 전체 섹션이 포함되어 있습니다. 원본 예제는 고전적인 함수형 스타일로 flatten 함수를 구현하는 것과:
type 'a list = ...
let rec length : 'a list -> int = ...
let rec concat : 'a list -> 'a list -> 'a list = ...
...
그리고 객체 지향 세계에 있었다면 flatten 메서드를 구현하는 것 사이의 대칭적인 관찰을 제공하며, 이 예제는 본 문서에서 소개된 문제점을 정확히 제기합니다. 질문은 다음과 같습니다: flatten은 어떤 타입을 가져야 하는가?
class type ['a] olist = object
method length : int
method concat : 'a olist -> 'a olist
...
따라서 필자는 다음과 같은 구문을 제안하며, 이는 flatten 메서드에 가드(guard)를 암시합니다:
method flatten : 'b olist with 'a = 'b olist
이 구문은 가드된 메서드를 설명할 수 있게 하며 다음과 같이 일반화될 수 있습니다: method method_name : return_type with generic_type = other_type
모듈(module)의 **치환(substitutions)**과 유사하게, 우리는 and를 사용하여 여러 제네릭에 대한 제약 조건을 지정할 수 있습니다.
예를 들어, 두 가지 타입을 매개변수로 받는 클래스: class ['a, 'b] t에 대해 다음과 같이 사용할 수 있습니다:
method foo : string with 'a = string and b = int
또한, 이 구문은 특정 동작을 우아하게 정의하는 것도 가능하게 합니다. 예를 들어, 우리의 olist 타입의 경우, 리스트 요소가 정수(integer)일 때 sum 메서드를 제공할 수 있습니다:
class type ['a] olist = object
method length : int
method concat : 'a olist -> 'a olist
...
이 모든 것이 비범하게 들리지만, 불행히도 이 구문은 OCaml에서 사용할 수 없습니다. 정말 짜증나죠! 걱정하지 마세요. 몇 가지 작은 도구를 사용하여 인코딩하는 것이 가능합니다.
OCaml에서의 가드된 메서드
가드된 메서드를 인코딩하는 데 사용할 도구에 대해 상당히 명확한 아이디어를 가지고 있었음에도 불구하고, 몇 가지 엣지 케이스(corner cases)를 접하면서 오직 질문을 하지 않지만 항상 광범위하게 답변해 주는 OCaml 커뮤니티의 누군가에게 도움을 청하기로 결정했습니다. 바로 Florian Angeletti, 별명 Octachron으로 알려진 사람입니다. (재미있는 참고 사항: octachron은 MIDI 드럼 시퀀서의 이름이므로, 그의 닉네임을 octachron ocaml에서 검색했을 때 알게 되었습니다.)
우리의 목표는 특정 메서드에 제약 조건을 추가하여 해당 수신자(receiver)의 타입이 그 제약 조건을 만족할 경우에만 접근 가능하도록 하는 것입니다. 언어 구문을 수정하지 않고도 제약을 모델링하는 것은 이를 강제하는 추가 매개변수를 제공하는 것으로 구성될 수 있습니다. 즉, 우리는 **증거(evidence)**를 제공하기를 원합니다.
타입 동등성 증명 제공하기
언어에 일반화된 대수적 자료형(generalized algebraic data types)이 도입된 이후, 타입 동등성 증명자(type equality witness)를 정의하는 상당히 간단한 방법이 있습니다:
type (_, _) eq =
| Refl : ('a, 'a) eq
eq
type만 있고 유일한 생성자 Refl을 가진 타입은, 타입 검사기(type-checker)가 알지 못하는 타입 동등성(type equalities)을 표현할 수 있게 합니다. 우리는 오직 두 개의 같은 타입을 연결하는 Refl 값을 구성할 수 있기 때문에, 스코프 내에서 Refl을 인스턴스화하면 해당 타입들이 동등하다는 것을 보장합니다. 예를 들면 다음과 같습니다:
type other_int = int
let _ : (int, other_int) eq = Refl
(* 이제 우리는 [int = other_int]의 증명(proof)을 가집니다. *)
이 예시는 다소 인위적인데, 여기서는 컴파일러가 int = other_int라는 것을 완벽하게 알고 있기 때문입니다. 하지만 컴파일러가 알 수 없는 경우도 있습니다. 예를 들어, 데이터가 런타임(runtime)에 제공되는 경우인데, 이 경우 타입 검사기는 타입에 대한 정보가 전혀 없어도 완벽하게 말이 되거나, 또는 타입의 표현이 추상화(abstraction)에 의해 숨겨진 경우입니다.
이 노트의 목표는 eq를 깊이 파고드는 것이 아니므로, 단지 Refl 값을 구성할 수 있다면 두 문법적으로 다른 타입이 실제로 동등하다는 보장이 있다는 점만 염두에 두면 됩니다.
eq
let으로 제약하기 돌아가서 리스트에 대한 객체 API를 제공하는 예시로 돌아가 보겠습니다. 인터페이스는 다음과 같습니다:
class type ['a] obj_list =
object ('self)
method length : int
...
flatten에 타입을 부여하기 위해, 'a(obj_list 클래스의 타입 매개변수)가 리스트라는 것을 강제하고 싶습니다. 즉, 'a가 'b list 타입이라는 증명을 원합니다. 다시 말해, 'a와 'b list가 문법적으로는 다르지만 동등하다는 것을 보장받고 싶은 것입니다. 간단합니다: ('a, 'b list) eq 타입의 값이 제공되도록 요구하기만 하면 됩니다:
method flatten : ('a, 'b list) eq -> 'b list
이제 리스트를 설명하고 flatten 메서드를 통해 수신자(receiver)가 something의 a 리스트임을 제약하는 인터페이스를 갖게 되었으니, obj_list를 구현하는 클래스를 구체적으로 구현해 보겠습니다.
obj_list
인터페이스 구현 첫 번째 메서드(length, append, 그리고 uncons)는 구현하기가 간단합니다:
let my_list (list : 'a list) =
object (self : 'a obj_list)
val l = list
...
이제 flatten에 집중해 봅시다.
우리는 리스트를 재귀적으로 순회하며 각 요소를 이전 요소와 연결합니다. 예를 들어, [[1]; [2]; [3]]는 [1] @ [2]; [3]가 됩니다.
다소 복잡한 어노테이션을 제외하고, 이 모든 트릭은 'a = 'b list라는 증거를 제공하기 위해 Refl을 인스턴스화하는 데 있습니다.
method flatten : 'b. ('a, 'b list) eq -> 'b list =
let rec aux : type a b. a #obj_list -> (a, b list) eq -> b list =
fun list witness -> match list#uncons with
...
sum
가드된 메서드를 추가하기: 특정 메서드를 제한할 수 있게 되었으니, 정수 리스트의 합을 계산하는 sum 메서드를 추가해 봅시다! 먼저, 인터페이스에 sum을 추가합니다. 이번에는 타입 매개변수를 int로 제한하고 싶습니다. 그렇게 하려면, 타입 동등성 증거(type equality witness)로서 단순히 ('a, int) eq를 사용합니다:
class type ['a] obj_list =
object ('self)
method length : int
...
다음으로, sum 메서드를 구현할 수 있는데, 이는 단지 fold_left 함수를 사용하는 것입니다.
method sum : ('a, int) eq -> int =
let aux : type a. a list -> (a, int) eq -> int = fun list Refl ->
List.fold_left (fun acc x -> acc + x) 0 list
...
sum의 구현은 추가적인 타입 변수를 도입하지 않기 때문에 flatten보다 논리적으로 더 간단합니다. 그리고 우리는 다양한 메서드를 테스트할 수 있습니다: 'a list obj_list 타입의 객체에 대해 flatten을 호출하고, int obj_list 타입의 객체에 대해 sum을 호출할 수 있습니다.
let a = my_list [ [ 1 ]; [ 2 ]; [ 3 ] ]
let _ = assert ([ 1; 2; 3 ] = a#flatten Refl)
let b = my_list [ 1; 2; 3; 4 ]
...
만약 잘못된 타입으로 가드된 메서드를 적용하려고 한다면, 예를 들어 우리의 리스트 a (타입이 'a list obj_list임)를 합산하려고 하면 프로그램이 컴파일되지 않습니다. 이는 우리가 부과된 제약을 존중하지 않고 가드된 메서드를 호출하려고 시도하기 때문에 당연합니다.
1 | let _ = a#sum Refl
^^^^
Error: This expression has type (int list, int list) eq
...
정확히 예상된 동작입니다! 이제 우리는 타입 동치성 증명(type equality witness)을 통해 수신자(receiver)의 타입을 제한하는 메서드를 정의할 수 있게 되었습니다. 임무 완료입니다!
## 결론
AI 자동 생성 콘텐츠
본 콘텐츠는 Lobste.rs ML의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기