객체
객체 (Objects)
OCaml은 객체지향이며, 명령형이고, 함수형인 프로그래밍 언어예요. 이 모든 패러다임을 섞어 쓰면서, 손에 닿은 작업에 가장 적합한(또는 가장 익숙한) 프로그래밍 패러다임을 고를 수 있게 해 주죠. 이번 장에서는 OCaml의 객체지향 프로그래밍을 살펴볼게요. 그런데 왜 객체지향 프로그램을 쓰고 싶을 수도, 싶지 않을 수도 있는지도 함께 이야기해 볼게요.
본문
객체와 클래스
교과서에서 객체지향 프로그래밍을 보여줄 때 빠지지 않는 고전적인 예가 스택 클래스예요. 여러모로 형편없는 예지만, 객체지향 OCaml 작성의 기초를 보여주는 데는 좋으니 여기서 써 볼게요.
정수 스택을 제공하는 기본 코드가 있어요. 클래스는 연결 리스트로 구현돼요.
# class stack_of_ints =
object (self)
val mutable the_list = ([] : int list) (* instance variable *)
method push x = (* push method *)
the_list <- x :: the_list
method pop = (* pop method *)
let result = List.hd the_list in
the_list <- List.tl the_list;
result
method peek = (* peek method *)
List.hd the_list
method size = (* size method *)
List.length the_list
end;;
class stack_of_ints :
object
val mutable the_list : int list
method peek : int
method pop : int
method push : int -> unit
method size : int
end
class name = object (self) ... end 기본 패턴은 name이라는 클래스를 정의해요.
클래스에는 인스턴스 변수 하나가 있는데, the_list라고 불리는 변경 가능한(mutable) 변수예요. (상수가 아니라요.) 이것이 기반 연결 리스트죠. 이걸 (stack_of_ints 객체가 만들어질 때마다) 익숙하지 않을 수 있는 코드로 초기화해요. ( [] : int list ) 표현식은 "타입 int list의 빈 리스트"라는 뜻이에요. 기억해 보면, 단순한 빈 리스트 []는 타입이 'a list, 즉 아무 타입의 리스트예요. 하지만 우리가 원하는 건 다른 게 아니라 int의 스택이에요. 그래서 타입 추론 엔진에게 이 리스트가 일반적인 "아무거나 담긴 리스트"가 아니라 더 좁은 "int의 리스트"라고 알려주고 싶어요. ( expression : type ) 문법은 타입이 type인 expression을 뜻해요. 이건 일반적인 타입 캐스트가 아니에요. 타입 추론 엔진을 무시하는 데는 쓸 수 없고, 일반 타입을 더 구체적으로 좁히는 데만 쓸 수 있거든요. 그래서 ( 1 : float ) 같은 건 쓸 수 없어요.
# (1 : float);;
Line 1, characters 2-3:
Error: This expression has type int but an expression was expected of type
float
Hint: Did you mean `1.'?
타입 안전성은 보존돼요. 다시 예제로 돌아와서 ...
이 클래스에는 간단한 메서드 네 개가 있어요.
push는 스택에 정수를 밀어 넣어요.pop은 스택 맨 위의 정수를 꺼내서 돌려줘요. 변경 가능한 인스턴스 변수를 갱신할 때 쓰는<-대입 연산자에 주목하세요. 레코드의 변경 가능한 필드를 갱신할 때 쓰는 것과 같은<-대입 연산자예요.peek는 스택에 영향을 주지 않고 맨 위를 돌려줘요. (즉 리스트의 머리)size는 스택의 요소 수를 돌려줘요. (즉 리스트의 길이)
int 스택을 시험할 코드를 좀 써 볼게요. 먼저 새 객체를 만들게요. 익숙한 new 연산자를 써요.
# let s = new stack_of_ints;;
val s : stack_of_ints = <obj>
이제 스택에 요소를 몇 개 밀어 넣고 꺼내 볼게요.
# for i = 1 to 10 do
s#push i
done;;
- : unit = ()
# while s#size > 0 do
Printf.printf "Popped %d off the stack.\n" s#pop
done;;
Popped 10 off the stack.
Popped 9 off the stack.
Popped 8 off the stack.
Popped 7 off the stack.
Popped 6 off the stack.
Popped 5 off the stack.
Popped 4 off the stack.
Popped 3 off the stack.
Popped 2 off the stack.
Popped 1 off the stack.
- : unit = ()
문법에 주목하세요. object#method는 object에서 method를 호출한다는 뜻이에요. 명령형 언어에서 익숙한 object.method나 object->method와 같죠.
OCaml 톱레벨에서 객체와 메서드의 타입을 더 자세히 살펴볼 수 있어요.
# let s = new stack_of_ints;;
val s : stack_of_ints = <obj>
# s#push;;
- : int -> unit = <fun>
s는 불투명한(opaque) 객체예요. 구현(즉 리스트)은 호출자에게 숨겨져 있어요.
다형적 클래스
정수 스택도 좋지만, 아무 타입이나 저장할 수 있는 스택은 어떨까요? (여러 타입을 섞어 저장하는 스택 하나가 아니라, 각각 아무 한 타입의 객체를 저장하는 여러 스택을 말해요.) 'a list처럼 'a stack을 정의할 수 있어요.
# class ['a] stack =
object (self)
val mutable list = ([] : 'a list) (* instance variable *)
method push x = (* push method *)
list <- x :: list
method pop = (* pop method *)
let result = List.hd list in
list <- List.tl list;
result
method peek = (* peek method *)
List.hd list
method size = (* size method *)
List.length list
end;;
class ['a] stack :
object
val mutable list : 'a list
method peek : 'a
method pop : 'a
method push : 'a -> unit
method size : int
end
class ['a] stack은 실제로 클래스 하나를 정의하는 게 아니에요. 가능한 모든 타입마다 하나씩, 즉 무한히 많은 클래스의 "클래스들의 클래스"를 정의하죠. 'a stack 클래스를 사용해 볼게요. 이번에는 스택을 만들고 부동소수점 숫자를 밀어 넣어요. 스택의 타입에 주목하세요.
# let s = new stack;;
val s : '_weak1 stack = <obj>
# s#push 1.0;;
- : unit = ()
# s;;
- : float stack = <obj>
이 스택은 이제 float stack이에요. 이 스택에서는 부동소수점 숫자만 밀어 넣고 꺼낼 수 있어요. 새 float stack의 타입 안전성을 보여드릴게요.
# s#push 3.0;;
- : unit = ()
# s#pop;;
- : float = 3.
# s#pop;;
- : float = 1.
# s#push "a string";;
Line 1, characters 8-18:
Error: This expression has type string but an expression was expected of type
float
아무 타입의 스택에서 동작하는 다형적 함수를 정의할 수 있어요. 첫 시도는 이거예요.
# let drain_stack s =
while s#size > 0 do
ignore (s#pop)
done;;
val drain_stack : < pop : 'a; size : int; .. > -> unit = <fun>
drain_stack의 타입에 주목하세요. 영리하게(어쩌면 너무 영리하게) OCaml의 타입 추론 엔진은 drain_stack이 pop과 size 메서드를 가진 어떤 객체에서도 동작한다는 것을 알아냈어요. 그래서 적합한 타입 시그니처를 가진 pop·size 메서드를 우연히 담고 있는 완전히 별개의 클래스를 정의했다면, 우연히 다른 타입의 객체에 drain_stack을 호출하게 될 수도 있어요.
drain_stack이 'a stack에서만 호출되도록 s 인자의 타입을 좁혀서 OCaml을 더 구체적으로 만들 수 있어요.
# let drain_stack (s : 'a stack) =
while s#size > 0 do
ignore (s#pop)
done;;
val drain_stack : 'a stack -> unit = <fun>
상속, 가상 클래스, 초기화
Java 프로그래머들이 상속을 지나치게 많이 쓰는 걸 봤어요. 아마 그 언어에서 코드를 확장하는 합리적인 방법이 상속밖에 없기 때문일 거예요. 코드를 확장하는 훨씬 더 좋고 일반적인 방법은 보통 훅(hooks, cf. Apache의 모듈 API)을 쓰는 거예요. 그럼에도 좁은 영역에서는 상속이 유용할 때가 있고, 그중 가장 중요한 곳이 GUI 위젯 라이브러리를 작성하는 거예요.
Java의 Swing과 비슷한 상상 속 OCaml 위젯 라이브러리를 생각해 볼게요. 버튼과 라벨을 다음 클래스 계층으로 정의해 볼게요.
widget (superclass for all widgets)
|
+----> container (any widget that can contain other widgets)
| |
| +----> button
|
+-------------> label
(button은 container라는 점을 주목하세요. 버튼에 무엇이 표시되느냐에 따라 라벨이나 이미지를 담을 수 있으니까요.)
widget은 모든 위젯의 가상 슈퍼클래스예요. 모든 위젯이 위젯 수명 동안 일정한 이름(그냥 문자열)을 가지길 원해요. 첫 시도는 이거였어요.
# class virtual widget name =
object (self)
method get_name =
name
method virtual repaint : unit
end;;
Error: Some type variables are unbound in this type:
class virtual widget :
'a ->
object method get_name : 'a method virtual repaint : unit end
The method get_name has type 'a where 'a is unbound
아차! OCaml이 name의 타입을 추론할 수 없다는 걸 잊어버렸어요. 그래서 'a라고 가정할 거예요. 하지만 그건 다형적 클래스를 정의하는 거고, 나는 클래스를 다형적이라 선언하지 않았어요 (class ['a] widget). name의 타입을 이렇게 좁혀야 해요.
# class virtual widget (name : string) =
object (self)
method get_name =
name
method virtual repaint : unit
end;;
class virtual widget :
string -> object method get_name : string method virtual repaint : unit end
이제 이 코드에는 새로 나온 것들이 몇 개 있어요. 첫째, 클래스에 초기화자(initialiser) 가 있어요. 이건 클래스에 대한 인자(name)인데, 예를 들어 Java의 생성자에 대한 인자와 정확히 같다고 생각하면 돼요.
public class Widget
{
public Widget (String name)
{
...
}
}
OCaml에서 생성자는 클래스 전체를 만들기 때문에, 특별히 이름 붙은 함수가 아니에요. 그래서 인자를 마치 클래스에 대한 인자처럼 써요.
class foo arg1 arg2 ... =
둘째, 클래스는 가상 메서드를 담고 있어서, 클래스 전체가 가상으로 표시돼요. 가상 메서드는 우리의 repaint 메서드예요. OCaml에게 가상이라고 알려줘야 하고(method virtual), 메서드의 타입도 알려줘야 해요. 메서드에 코드가 없으니 OCaml이 타입 추론으로 자동으로 타입을 알아낼 수 없어서, 타입을 직접 알려줘야 하죠. 이 경우 메서드는 그냥 unit을 돌려줘요. 클래스에 가상 메서드가 하나라도 있으면(상속받은 것만 있더라도) class virtual ...으로 클래스 전체를 가상으로 지정해야 해요.
C++와 Java에서처럼, 가상 클래스는 new로 직접 인스턴스화할 수 없어요.
# let w = new widget "my widget";;
Error: Cannot instantiate the virtual class widget
이제 container 클래스가 더 재미있어요. widget에서 상속받고, 담긴 위젯들의 리스트를 저장하는 메커니즘을 가져야 해요. container의 간단한 구현이에요.
# class virtual container name =
object (self)
inherit widget name
val mutable widgets = ([] : widget list)
method add w =
widgets <- w :: widgets
method get_widgets =
widgets
method repaint =
List.iter (fun w -> w#repaint) widgets
end;;
class virtual container :
string ->
object
val mutable widgets : widget list
method add : widget -> unit
method get_name : string
method get_widgets : widget list
method repaint : unit
end
참고:
container클래스는 가상으로 표시됐어요. 가상 메서드는 담고 있지 않지만, 이 경우 사람들이 container를 직접 만들지 못하게 막는 역할을 해요.container클래스는name인자를 가지는데,widget을 구성할 때 그걸 그대로 위로 전달해요.inherit widget name은container가widget에서 상속받고,widget의 생성자에name인자를 전달한다는 뜻이에요.- 이
container는 변경 가능한 위젯 리스트와, 여기에 위젯을add하는 메서드,get_widgets(위젯 리스트를 돌려주는) 메서드를 담고 있어요. get_widgets가 돌려주는 위젯 리스트는 클래스 바깥의 코드로 수정할 수 없어요. 그 이유는 다소 미묘하지만, 기본적으로 OCaml의 연결 리스트가 불변이라는 점에서 옵니다. 누군가 이런 코드를 썼다고 상상해 볼게요.
# let list = container#get_widgets in
x :: list;;
이게 x를 위젯 리스트 앞에 붙여서 내 container 클래스의 비공개 내부 표현을 수정할까요? 아니요. 위 코드를 실행해 보면 오류가 나는 걸 볼 수 있어요.
Error: Unbound value container
그래도 private 변수 widgets는 이 시도나 다른 시도에도 영향을 받지 않아요. 이 말은, 예를 들어 나중에 내부 표현을 배열로 바꿔도 클래스 바깥의 코드는 하나도 바꿀 필요가 없다는 뜻이에요.
마지막으로, 빠뜨릴 수 없는 것으로, 이전에 가상이던 repaint 함수를 구현해서 container#repaint가 담긴 모든 위젯을 다시 그리게 했어요. 리스트를 순회하는 데 List.iter를 쓴 것과, 익숙하지 않을 수 있는 익명 함수 표현식을 쓴 것에 주목하세요.
# (fun w -> w#repaint);;
- : < repaint : 'a; .. > -> 'a = <fun>
이건 인자 w 하나를 받아서 그냥 w#repaint(위젯 w의 repaint 메서드)를 호출하는 익명 함수를 정의해요.
이번에는 button 클래스가 간단해요 (사실 비현실적으로 단순하지만, 그건 신경 쓰지 마세요).
# type button_state = Released | Pressed;;
type button_state = Released | Pressed
# class button ?callback name =
object (self)
inherit container name as super
val mutable state = Released
method press =
state <- Pressed;
match callback with
| None -> ()
| Some f -> f ()
method release =
state <- Released
method repaint =
super#repaint;
print_endline ("Button being repainted, state is " ^
(match state with
| Pressed -> "Pressed"
| Released -> "Released"))
end;;
class button :
?callback:(unit -> unit) ->
string ->
object
val mutable state : button_state
val mutable widgets : widget list
method add : widget -> unit
method get_name : string
method get_widgets : widget list
method press : unit
method release : unit
method repaint : unit
end
참고:
- 이 함수는 선택적 인자(이전 장 참고)를 갖는데, 선택적 콜백 함수를 넘기는 데 쓰여요. 콜백은 버튼이 눌렸을 때 호출돼요.
inherit container name as super표현식은 슈퍼클래스에super라는 이름을 붙여 줘요.repaint메서드에서 이걸 써요:super#repaint. 이건 슈퍼클래스 메서드를 명시적으로 호출해요.- 버튼을 누르면(이 단순한 코드에서
button#press를 호출하면) 버튼을Pressed로 설정하고, 콜백 함수가 정의되어 있으면 그걸 호출해요.callback변수는None또는Some f라는 점에 주목하세요. 즉 타입이(unit -> unit) option이에요. 확실하지 않으면 이전 장을 다시 읽어 보세요. callback변수의 이상한 점에 주목하세요. 클래스에 대한 인자로 정의됐는데, 어떤 메서드든 보고 쓸 수 있어요. 다시 말해, 이 변수는 객체가 구성될 때 제공되고, 객체의 수명 동안 유지돼요.repaint메서드가 구현됐어요. 슈퍼클래스(container를 다시 그리기 위해)를 호출한 다음 버튼을 다시 그리면서 버튼의 현재 상태를 표시해요.
label 클래스를 정의하기 전에, 톱레벨에서 button 클래스를 좀 갖고 놀아 볼게요.
# let b = new button ~callback:(fun () -> print_endline "Ouch!") "button";;
val b : button = <obj>
# b#repaint;;
Button being repainted, state is Released
- : unit = ()
# b#press;;
Ouch!
- : unit = ()
# b#repaint;;
Button being repainted, state is Pressed
- : unit = ()
# b#release;;
- : unit = ()
비교적 사소한 label 클래스가 여기 있어요.
# class label name text =
object (self)
inherit widget name
method repaint =
print_endline ("Label: " ^ text)
end;;
class label :
string ->
string -> object method get_name : string method repaint : unit end
"Press me!"라고 말하는 라벨을 만들고 버튼에 추가해 볼게요.
# let l = new label "label" "Press me!";;
val l : label = <obj>
# b#add l;;
- : unit = ()
# b#repaint;;
Label: Press me!
Button being repainted, state is Released
- : unit = ()
self에 대한 참고
위의 모든 예제에서 우리는 일반적인 패턴으로 클래스를 정의했어요.
class name =
object (self)
(* ... *)
end
self에 대한 참조는 객체에 이름을 붙여서, 같은 클래스의 메서드를 호출하거나 객체를 클래스 바깥의 함수에 넘길 수 있게 해 줘요. 다시 말해 C++/Java의 this와 정확히 같아요. 자기 자신을 참조할 필요가 없다면 (self) 부분을 완전히 생략할 수 있어요. 실제로 위의 모든 예제에서 그렇게 할 수 있었죠. 그런데도 남겨 두길 권해요. 클래스를 수정하다가 self 참조가 필요해질지 모르니까요. 있어도 손해는 없거든요.
상속과 강제 변환(coercion)
# let b = new button "button";;
val b : button = <obj>
# let l = new label "label" "Press me!";;
val l : label = <obj>
# [b; l];;
Error: This expression has type label but an expression was expected of type
button
The first object type has no method add
버튼 b와 라벨 l을 만들고 둘 다 담는 리스트를 만들려고 했지만 오류가 났어요. 그런데 b와 l은 둘 다 widget이에요. 어쩌면 OCaml이 widget list를 원한다는 걸 짐작하지 못해서 같은 리스트에 못 넣는 걸 수도 있죠. 알려주는 걸 시도해 볼게요.
# let wl = ([] : widget list);;
val wl : widget list = []
# let wl = b :: wl;;
Error: This expression has type widget list
but an expression was expected of type button list
Type widget = < get_name : string; repaint : unit >
is not compatible with type
button =
< add : widget -> unit; get_name : string;
get_widgets : widget list; press : unit; release : unit;
repaint : unit >
The first object type has no method add
알고 보니 OCaml은 기본적으로 하위 클래스를 슈퍼클래스 타입으로 강제 변환하지 않아요. 하지만 :>(강제 변환) 연산자를 쓰면 알려줄 수 있어요.
# let wl = (b :> widget) :: wl;;
val wl : widget list = [<obj>]
# let wl = (l :> widget) :: wl;;
val wl : widget list = [<obj>; <obj>]
(b :> widget) 표현식은 "버튼 b를 타입 widget으로 강제 변환한다"는 뜻이에요. 강제 변환이 성공할지는 컴파일 시점에 완전히 알 수 있기 때문에 타입 안전성은 보존돼요.
사실 강제 변환은 위에서 설명한 것보다 다소 더 미묘하니, 자세한 내용은 매뉴얼을 읽어 보세요.
위에서 정의한 container#add 메서드는 사실 틀렸어요. 서로 다른 타입의 위젯들을 container에 추가하려고 하면 실패하거든요. 강제 변환이 이걸 고쳐 줘요.
슈퍼클래스(예: widget)에서 하위 클래스(예: button)로 강제 변환하는 게 가능할까요? 놀랍겠지만 답은 아니요! 이 방향으로 강제 변환하는 건 안전하지 않아요. 실제로는 label이지 button이 아닌 widget을 강제 변환하려 시도할 수도 있으니까요.
Oo 모듈과 객체 비교
Oo 모듈에는 OO 프로그래밍에 유용한 함수가 몇 개 있어요.
Oo.copy는 객체의 얕은 복사본을 만들어요. Oo.id object는 객체마다 고유한 식별 번호를 돌려줘요. (모든 클래스에 걸쳐 고유한 번호예요.)
=와 <>는 객체를 물리적(physical) 동등성으로 비교하는 데 쓸 수 있어요. (객체와 그 복사본은 물리적으로 동일하지 않죠.) < 등도 쓸 수 있는데, 겉보기에 객체의 ID에 기반한 순서를 제공해요.
클래스 없는 객체
여기서는 클래스를 반드시 쓰지 않고도 객체를 레코드처럼 사용하는 방법을 살펴볼게요.
즉시 객체와 객체 타입
객체는 레코드 대신 쓸 수 있어요. 게다가 어떤 경우에는 레코드보다 선호하게 만드는 좋은 성질도 있어요. 객체를 만드는 정식 방법은 먼저 클래스를 정의하고, 그 클래스로 개별 객체를 만드는 것이었어요. 이건 어떤 상황에서는 번거로운데, 클래스 정의는 타입 정의 이상이어서 타입과 함께 재귀적으로 정의할 수 없기 때문이에요. 하지만 객체는 레코드 타입과 매우 비슷한 타입을 가지며, 타입 정의에 쓸 수 있어요. 게다가 객체는 클래스 없이도 만들 수 있어요. 그런 걸 즉시 객체(immediate object) 라고 해요. 즉시 객체의 정의가 여기 있어요.
# let o =
object
val mutable n = 0
method incr = n <- n + 1
method get = n
end;;
val o : < get : int; incr : unit > = <obj>
이 객체는 공개 메서드로만 정의되는 타입을 가져요. 값도 보이지 않고 private 메서드도 보이지 않아요. (표시되지 않았죠.) 레코드와 달리 이런 타입은 명시적으로 미리 정의할 필요가 없지만, 미리 정의하면 더 명확해질 수 있어요. 이렇게 할 수 있어요.
# type counter = <get : int; incr : unit>;;
type counter = < get : int; incr : unit >
동등한 레코드 타입 정의와 비교해 볼게요.
# type counter_r =
{get : unit -> int;
incr : unit -> unit};;
type counter_r = { get : unit -> int; incr : unit -> unit; }
우리 객체처럼 동작하는 레코드의 구현은 이렇게 될 거예요.
# let r =
let n = ref 0 in
{get = (fun () -> !n);
incr = (fun () -> incr n)};;
val r : counter_r = {get = <fun>; incr = <fun>}
기능적으로는 객체와 레코드가 비슷하지만, 각 해법마다 저마다의 장점이 있어요.
- 속도: 레코드는 필드 접근이 약간 더 빨라요.
- 필드 이름: 서로 다른 타입의 레코드를 다룰 때 일부 필드 이름이 같으면 불편한데, 객체에서는 문제가 되지 않아요.
- 서브타입: 레코드 타입을 필드가 더 적은 타입으로 강제 변환하는 건 불가능해요. 하지만 객체에서는 가능하죠. 그래서 공통 메서드 몇 개를 공유하는 서로 다른 종류의 객체들을, 공통 메서드만 보이는 데이터 구조에 섞을 수 있어요. (다음 섹션 참고).
- 타입 정의: 객체 타입을 미리 정의할 필요가 없어서, 모듈 사이의 의존성 제약을 줄여 줘요.
클래스 타입 대 그냥 타입
클래스 타입(class type) 과 객체 타입 을 혼동하지 않도록 주의하세요. 클래스 타입 은 데이터 타입 이 아니에요. (OCaml 용어에서 보통 type 이라고 부르는 것 말이에요.) 객체 타입 은 레코드 타입이나 튜플처럼 일종의 데이터 타입 이에요.
클래스가 정의되면 같은 이름의 클래스 타입 과 객체 타입 둘 다 정의돼요.
# class t =
object
val x = 0
method get = x
end;;
class t : object val x : int method get : int end
object val x : int method get : int end가 클래스 타입이에요.
이 예제에서 t는 이 클래스가 만들 객체들의 타입이기도 해요. 서로 다른 클래스에서 왔거나 아예 클래스가 아닌 곳에서 온(즉시 객체) 객체들도 타입이 같으면 섞을 수 있어요.
# let x = object method get = 123 end;;
val x : < get : int > = <obj>
# let l = [new t; x];;
val l : t list = [<obj>; <obj>]
공통 하위 타입을 공유하는 객체를 섞는 것도 가능하지만, :> 연산자로 명시적 타입 강제 변환이 필요해요.
# let x = object method get = 123 end;;
val x : < get : int > = <obj>
# let y = object method get = 80 method special = "hello" end;;
val y : < get : int; special : string > = <obj>
# let l = [x; y];;
Error: This expression has type < get : int; special : string >
but an expression was expected of type < get : int >
The second object type has no method special
# let l = [x; (y :> t)];;
val l : t list = [<obj>; <obj>]