0%

16. 다중 디스패치

글을 시작하기에 앞서 해당 시리즈는 Allen Downey, Ben Lauwens의 저서인 Think Julia: How to Think Like a Computer Scientist 를 바탕으로 작성된 글임을 알려드립니다.

이 포스트는 Multiple Dispatch를 한글로 요약 정리한 글입니다.

다중 디스패치 (Multiple Dispatch)

줄리아에서는 다른 데이터 타입들에서도 작동하는 코드를 작성할 수 있으며, 해당 코드를 ‘제네릭 프로그래밍(generic programming)’이라고 한다.

이번 장에서는 줄리아에서 데이터 타입 선언 사용하는 방법에 설명하고 인수의 데이터 타입에 따라 다른 작동을 구현하는 함수인 다중 디스패치(Multiple Dispatch)에 대해 알아볼 것이다.

데이터 타입 선언 (Type Declarations)

:: 연산자는 변수나 표현식에 ‘데이터 타입 주석(type annotations)’을 붙인다.

1
2
3
4
julia> (1 + 2) :: Float64
ERROR: TypeError: in typeassert, expected Float64, got Int64
julia> (1 + 2) :: Int64
3

이런 방식은 개발자가 예측한 대로 프로그램이 작동하는지 확인하는데 도움을 준다.

또한 :: 연산자는 변수 옆인 할당문 왼쪽 측면에도 붙을 수도 있다.

1
2
3
4
5
6
7
8
9
julia> function returnfloat()
x::Float64 = 100
x
end
returnfloat (generic function with 1 method)
julia> x = returnfloat()
100.0
julia> typeof(x)
Float64

변수 x는 데이터 타입이 항상 Float64이며, 값은 부동 소수점으로 변환된다.

또한 데이터 타입 주석은 함수의 정의 헤더 부분에도 불일 수 있다.

1
2
3
4
5
6
function sinc(x)::Float64
if x == 0
return 1
end
sin(x)/(x)
end

sinc()의 반환 값은 항상 Float64로 변환된다.

데이터 타입이 생략될 때, 줄리아에서는 항상 데이터 타입 Any을 값으로 사용할 수 있다.

메서드 (Methods)

구조체와 함수에서 MyTime이라는 구조체를 정의하였다. printtime()이라는 함수를 사용하여 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
using Printf

struct MyTime
hour :: Int64
minute :: Int64
second :: Int64
end

function printtime(time)
@printf("%02d:%02d:%02d", time.hour, time.minute, time.second)
end

보다시피, 데이터 타입 선언은 성능상의 이유로 구조체 정의의 필드에 추가될 수 있다.

위 함수를 호출하려면, MyTime 객체를 인수로 전달해야 한다.

1
2
3
4
julia> start = MyTime(9, 45, 0)
MyTime(9, 45, 0)
julia> printtime(start)
09:45:00

MyTime 객체만을 인수로 받는다는 메서드를 printtime()에 추가하기 위해서는 함수 정의에 ::MyTime을 인수로 작성하면 된다.

1
2
3
function printtime(time::MyTime)
@printf("%02d:%02d:%02d", time.hour, time.minute, time.second)
end

메서드는 특정 기호가 포함된 함수 정의이다. printtime()MyTime 객체만을 인수로 받는다.

printtime()MyTime 객체로 호출하는 것은 아래와 같은 결과를 반환한다.

1
2
julia> printtime(start)
09:45:00

아래는 ::을 사용하지 않고 함수를 재정의하여 모든 데이터 타입을 인수로 허용한 함수이다.

1
2
3
function printtime(time)
println("I don't know how to print the argument time.")
end

만약 MyTime 객체가 아닌 다른 데이터 타입을 인수로 하여 printtime()을 호출한다면 아래와 같은 결과를 얻는다.

1
2
julia> printtime(150)
I don't know how to print the argument time.

추가적 예시들 (Additional Examples)

앞서 15장에서 사용했던 increment()에 인수를 세부적으로 설정하여 재정의할 것이다.

1
2
3
4
function increment(time::MyTime, seconds::Int64)
seconds += timetoint(time)
inttotime(seconds)
end

한번 더 짚고 넘어가자면, 위의 함수는 ‘순수 함수(pure function)’이며 ‘변경자(modifier)’는 아니다.

아래의 코드는 increment()를 불러오는 방법이다.

1
2
3
4
julia> start = MyTime(9, 45, 0)
MyTime(9, 45, 0)
julia> increment(start, 1337)
MyTime(10, 7, 17)

인수 순서를 바꿔서 넣는다면, 오류 메시지를 얻는다.

1
2
julia> increment(1337, start)
ERROR: MethodError: no method matching increment(::Int64, ::MyTime)

그 이유는 메서드에 정의된 순서가 increment(time::MyTime, seconds::Int64)이지 increment(seconds::Int64, time::MyTime)가 아니기 때문이다.

다음으로는 두 인수를 비교하는 isafter()MyTime 객체만 인수로 받을 수 있도록 설정해보자.

1
2
3
function isafter(t1::MyTime, t2::MyTime)
(t1.hour, t1.minute, t1.second) > (t2.hour, t2.minute, t2.second)
end

또한 선택적 인수를 사용하면 ‘다중 메서드 정의(multiple method definitions)’가 구현된 셈이다. 예시는 아래와 같다.

1
2
3
function f(a=1, b=2)
a + 2b
end

위 함수는 아래의 세 가지 메서드로 변환된다.

1
2
3
f(a, b) = a + 2b
f(a) = f(a, 2)
f() = f(1, 2)

이 표현식들은 유효한 줄리아 메서드 정의이며, 함수 또는 메서드를 정의하는 단축 표기 방법이다.

생성자 (Constructors)

생성자(Constructors)는 객체를 만들기 위해서 호출되는 특별한 함수이다. MyTime의 기본 생성자 메서드는 아래와 같다.

1
2
MyTime(hour, minute, second)
MyTime(hour::Int64, minute::Int64, second::Int64)

또한 자체 ‘외부 생성자(outer constructor)’ 메서드를 추가할 수 있다.

1
2
3
function MyTime(time::MyTime)
MyTime(time.hour, time.minute, time.second)
end

이 메서드는 새로운 MyTime 객체가 인수의 복사본이기 때문에 ‘복사 생성자(copy constructor)’라고도 부른다.

인수를 변경하지 못하게 하려면, ‘내부 생성자(inner constructor)’ 메서드가 필요하다.

1
2
3
4
5
6
7
8
9
10
struct MyTime
hour :: Int64
minute :: Int64
second :: Int64
function MyTime(hour::Int64=0, minute::Int64=0, second::Int64=0)
@assert(0 ≤ minute < 60, "Minute is not between 0 and 60.")
@assert(0 ≤ second < 60, "Second is not between 0 and 60.")
new(hour, minute, second)
end
end

이제 구조체인 MyTime은 4개의 내부 생성자 메서드를 가지고 있다.

1
2
3
4
MyTime()
MyTime(hour::Int64)
MyTime(hour::Int64, minute::Int64)
MyTime(hour::Int64, minute::Int64, second::Int64)

내부 생성자 메서드는 항상 데이터 타입 선언 블럭 내에 정의되며, 새로 선언된 데이터 타입의 객체를 만드는 new()라는 특수 함수에 접근할 수 있다.

WARNING
내부 생성자가 정의되었다면 기본 생성자는 사용할 수 없다. 따라서 필요한 내부 생성자는 명확하게 작성해야 한다.

로컬 함수인 new()의 인수 없이 사용하는 두 번째 메서드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mutable struct MyTime
hour :: Int
minute :: Int
second :: Int
function MyTime(hour::Int64=0, minute::Int64=0, second::Int64=0)
@assert(0 ≤ minute < 60, "Minute is between 0 and 60.")
@assert(0 ≤ second < 60, "Second is between 0 and 60.")
time = new()
time.hour = hour
time.minute = minute
time.second = second
time
end
end

위 코드는 필드 하나가 자체 구조체가 되는 ‘재귀 데이터 구조(recursive data structures)’를 가능하게 한다. 해당 예시의 구조체는 인스턴스화 이후에 필드가 수정되기 때문에 변경 가능해야 한다.

show

show()는 객체를 문자열로 표현하여 반환하는 특별한 함수이다. 아래의 코드는 MyTime 객체에 show 메서드를 사용한 예시이다.

1
2
3
4
5
using Printf

function Base.show(io::IO, time::MyTime)
@printf(io, "%02d:%02d:%02d", time.hour, time.minute, time.second)
end

Base.show 함수에 새로운 메서드를 추가하고 싶기 때문에 show앞에 Base를 작성하였다.

객체를 출력하면 줄리아는 show()를 실행한다.

1
2
julia> time = MyTime(9, 45)
09:45:00

새로운 복합 데이터 타입을 작성할 때는 항상 외부 생성자를 사용한다. 이렇게 하면 객체와 show를 인스턴스하기 더 쉬워져서 디버깅에 유용하다.

연산자 오버로딩 (operator overloading)

연산자 메서드를 정의함으로써, 개발자가 정의한 고유 데이터 타입 안에서 연산자들이 어떻게 작동하는지를 지정할 수 있다. 예를 들어 만약 두 개의 MyTime 인수와 +메서드를 정의하였다면, MyTime 객체에서 + 연산자를 사용할 수 있다.

1
2
3
4
5
6
import Base.+

function +(t1::MyTime, t2::MyTime)
seconds = timetoint(t1) + timetoint(t2)
inttotime(seconds)
end

import문은 메서드를 추가할 수 있도록 + 연산자를 로컬 범위에서 가져온다.

아래의 코드는 정의한 + 연산자를 어떻게 사용하는지를 보여준다.

1
2
3
4
5
6
julia> start = MyTime(9, 45)
09:45:00
julia> duration = MyTime(1, 35, 0)
01:35:00
julia> start + duration
11:20:00

이제부터는 + 연산자를 MyTime객체와 사용하면 줄리아는 위에서 만든 메서드를 가져오며, REPL이 결과를 보여줄 때는 줄리아가 앞에서 작성한 show를 사용하여 보여준다.

개발자가 정의한 데이터 타입에서 작동하도록 연산자들의 작동을 추가하는 것을 ‘연산자 오버로딩(operator overloading)’이라고 한다.

다중 디스패치 (Multiple dispatch)

이전 섹션에서는 MyTime객체끼리 더하는 과정을 보았다. 하지만 만약 MyTime객체에 정수를 더하고 싶다면 어떻게 해야할까?

1
2
3
function +(time::MyTime, seconds::Int64)
increment(time, seconds)
end

위의 함수는 + 연산자가 MyTime객체와 정수가 더해지는 방법을 메서드로 정의하였다.

두 개를 더한 결과는 아래와 같다.

1
2
3
4
julia> start = MyTime(9, 45)
09:45:00
julia> start + 1337
10:07:17

덧셈은 교환법칙이 성립하기 때문에 인수 순서가 변경된 메서드도 추가한다.

1
2
3
function +(seconds::Int64, time::MyTime)
time + seconds
end

인수 순서를 바꿔도 똑같은 결과를 얻을 수 있다.

1
2
julia> 1337 + start
10:07:17

함수를 적용할 때 어떤 메서드가 적합한지 선택하는 것을 ‘디스패치(dispatch)’라고 한다. 줄리아는 디스패치 프로세스가 주어진 인수의 수와 데이터 타입에 따라 호출할 함수의 메서드를 선택할 수 있도록 한다. 함수의 인수를 모두 사용하여 호출할 메서드를 선택하는 것을 ‘다중 디스패치(multiple dispatch)’라고 한다.

제네릭 프로그래밍 (Generic Programming)

다중 디스패치는 유용하지만 항상 필요한 것은 아니다. 다른 데이터 타입을 인수로 하여 함수를 작성하면 다중 디스패치를 사용하지 않아도 된다.

지금까지 문자열에 대해 작성한 많은 함수들은 다른 데이터 타입의 시퀀스에도 작동한다. 예를 들어, 딕셔너리로 카운팅하기에서 사용한 단어의 개수를 세어주는 histogram() 을 보자.

1
2
3
4
5
6
7
8
9
10
11
function histogram(s)
d = Dict()
for c in s
if c ∉ keys(d)
d[c] = 1
else
d[c] += 1
end
end
d
end

이 함수에서 s의 요소는 해시(hashable)할 수 있기에 딕셔너리의 키(key)로 사용될 수 있고, 따라서 리스트, 튜플, 심지어 딕셔너리에서도 작동한다.

1
2
3
4
5
6
7
julia> t = ("spam", "egg", "spam", "spam", "bacon", "spam")
("spam", "egg", "spam", "spam", "bacon", "spam")
julia> histogram(t)
Dict{Any,Any} with 3 entries:
"bacon" => 1
"spam" => 4
"egg" => 1

여러 데이터 타입에서도 작동하는 함수들을 폴리모픽(polymorphic)이라고 한다. 폴리모픽은 코드를 재사용할 수 있도록 도와준다.

예를 들어, 시퀀스의 요소들의 합을 제공하는 내장함수인 sum()은 덧셈을 가지고 있는 모든 데이터 타입의 시퀀스에 작동한다.

1
2
3
4
5
6
7
8
julia> t1 = MyTime(1, 7, 2)
01:07:02
julia> t2 = MyTime(1, 5, 8)
01:05:08
julia> t3 = MyTime(1, 5, 0)
01:05:00
julia> sum((t1, t2, t3))
03:17:10

일반적으로 함수 내부의 모든 연산들이 지정된 데이터 타입으로 작동하면, 함수는 그 유형에도 작동된다.

폴리모픽의 가장 좋은 종류는 의도하지 않았는데 이미 작성된 함수가 해당 데이터 타입에 적용가능하다는 것을 알게되는 것이다.

인터페이스와 구현 (Interface and Implementation)

다중 디스패치의 목적 중 하나는 소프트웨어의 유지 및 보수를 쉽게 만드는 것이다. 즉, 시스템의 다른 부분이 변경될 때 프로그램을 계속 작동시키고 새로운 요구 사항을 충족하도록 프로그램을 수정하는 것이다.

이러한 목표를 달성하는 데 도움이 되는 디자인 원칙은 인터페이스(interface)를 구현(implementation)과 분리하는 것이다. 즉 데이터 타입이 지정된 인수를 가진 메서드는 해당 데이터 타입의 필드가 어떻게 표현되는지에 의존해서는 안된다는 것을 의미한다.

예를 들어, 이 장에서 우리는 시간을 나타내는 구조체를 개발했다. 이 데이터 타입으로 지정된 인수가 있는 메서드는 timetoint, isafter 그리고 +가 있다. 우리는 이 메서드들을 여러 방법으로 구현할 수 있다. 구현의 세부 사항들은 MyTime을 어떻게 표현하는지에 의존한다. 이 장에서 MyTime의 필드는 hour, minute, second 이다.

다른 방법으로는 자정 이후로 second의 수를 단일 정수로 나타낸 필드로 대체할 수 있다. 이 구현은 isafter와 같은 함수를 더 쉽게 작성할 수 있도록 하지만 다른 함수들을 더 어렵게 만든다.

새로운 데이터 타입을 배포한 후 더 좋은 구현을 발견할 수 있다. 만약 프로그램의 다른 부분이 해당 데이터 타입을 사용중이라면, 인터페이스를 변경하는데 시간이 많이 걸리고 오류가 발생할 수 있다.

그러나 인터페이스를 신중하게 디자인한 경우 인터페이스를 변경하지 않고 구현을 변경할 수 있다. 즉, 프로그램의 다른 부분은 변경할 필요가 없게 된다.

디버깅

만약 함수에 대해 둘 이상의 메서드를 지정한다면, 적절한 인수를 사용하여 함수를 호출하기가 어려울 수 있다. 줄리아는 함수 메서드를 조사할 수 있다.

메서드가 주어진 함수에서 사용할 수 있는지 알기 위해서는 methods()를 사용하면 된다.

1
2
3
4
julia> methods(printtime)
# 2 methods for generic function "printtime":
[1] printtime(time::MyTime) in Main at REPL[3]:2
[2] printtime(time) in Main at REPL[4]:2

이 예시에서는 printtime()가 2개의 메서드를 가지고 있으며, 하나는 MyTime 인수이고 다른 하나는 Any 인수이다.