조우

조우

학습 연습 문제

소개

이 강의를 따라오면서 이미 함수 정의를 아주 많이 봐 왔을 거예요. 그런데 보통은 REPL에 나오는 출력에 대한 설명 없이 지나갔죠:

julia> f(x) = x^2
f (generic function with 1 method)

왜 "generic function with 1 method" 일까요?

인자 타입을 좀 더 구체적으로 지정하고 싶다고 해 봐요.

julia> f(x::Integer) = x^2
f (generic function with 2 methods)

julia> f(x::Real) = x^2
f (generic function with 3 methods)

아니면 인자의 개수를 바꿀 수도 있어요:

julia> f(x, y) = x * y
f (generic function with 4 methods)

같은 함수를 새로 정의한 것을 "메서드"라고 부르는 것 같아요. 메서드는 예전 정의를 덮어쓰는 대신 목록에 추가돼요.

메서드 목록은 이렇게 쉽게 볼 수 있어요:

julia> methods(f)  # output edited for brevity
# 4 methods for generic function "f" from Main:
 [1] f(x, y)
 [2] f(x::Integer)
 [3] f(x::Real)
 [4] f(x)

함수 f를 호출하면, Julia는 어떤 메서드를 사용해야 할지 알아서 판단해요:

julia> f(2.5)  # Float64
6.25

julia> f(2)  # Int64
4

julia> f(2, 3)  # Int64, Int64
6

함수란 무엇일까요?

매뉴얼에 따르면, 함수는 인자로 이루어진 튜플을 반환값으로 대응시키는 객체예요.

우리는 보통 f(2, 3)의 인자를 서로 별개의 두 항목으로 생각하지만, 컴파일러는 이 둘을 하나의 튜플로 봐요. 인자가 하나뿐일 때도 마찬가지예요(다만 이 점을 분명히 하려면 쉼표를 붙여야 하죠):

julia> typeof( (2, 3) )
Tuple{Int64, Int64}

julia> typeof( (2,) )
Tuple{Int64}

함수를 블랙박스로 보면, 튜플이 들어가고 반환값이 나온다고 말할 수 있어요. 더 엄밀히 말하면, 튜플이 반환값으로 _대응_돼요.

함수는 인자의 개수와 타입이 다를 때 어떻게 처리할까요?

이런 것을 보통 "다형성"이라고 불러요.

수십 년 동안 컴퓨터 과학의 큰 주제였고, 여러 가지 접근 방식이 채택되어 왔어요.

Julia의 접근 방식은 독특하지만, 유일한 것은 아니에요.

  • 함수는 "제네릭"해서, 여러 메서드를 붙일 수 있어요.
  • 함수는 메서드가 붙기 전까지는 그저 이름일 뿐이에요.
  • 메서드는 필요할 때마다 추가할 수 있고, 아주 많아질 수도 있어요(현재 + 연산자는 메서드가 161개인 함수예요).
  • 함수를 호출하면, Julia는 호출 시그니처, 즉 인자의 개수와 각 인자의 현재 타입을 살펴봐요.
  • 호출 시그니처에 가장 잘 맞는 메서드가 선택돼요.
    • 일반적으로 인자의 개수는 정확히 일치해야 해요(... 스플랫/슬러프 연산자로 이 규칙을 어느 정도 우회할 수 있기는 하죠).
    • 타입이 호출 시그니처와 정확히 일치하는 메서드가 우선돼요.
    • 필요하면 Julia는 호출 시그니처에 있는 각 인자의 슈퍼타입까지 살펴서, 가장 잘 맞고 가장 구체적인 메서드를 찾아요.
  • 선택된 메서드는 주어진 인자로 dispatched되어 결과를 반환해요.
  • 적절한 메서드를 찾지 못하면 MethodError가 발생해요.

Julia는 동적 타이핑을 사용하므로, 메서드 디스패치는 _런타임_에 일어난다는 점을 기억해 두세요.

다중 디스패치: 예제

앞 절은 말이 많았으니, 이번에는 그 아이디어를 코드로 옮겨 봐요. 간단히 하기 위해 아래 함수들은 인자가 하나뿐이지만, 인자가 수십 개인 함수도 흔해요(플로팅 패키지나 머신러닝에서처럼요).

먼저 도형을 나타내는 커스텀 타입 몇 개를 정의해 봐요.

julia> abstract type Shape end

julia> struct Circle <: Shape
           radius::Float64
       end

julia> struct Square <: Shape
           side::Float64
       end

julia> struct Rectangle <: Shape
           length::Float64
           width::Float64
       end

다음으로 각 도형의 넓이를 계산하는 메서드를 정의할 수 있어요. 각 메서드는 인자를 하나씩 받고, 그 타입은 매번 서로 다른 구체적인 사용자 정의 타입이에요.

julia> area(c::Circle) = π * c.radius^2
area (generic function with 1 method)

julia> area(s::Square) = s.side^2
area (generic function with 2 methods)

julia> area(r::Rectangle) = r.length * r.width
area (generic function with 3 methods)

이제 도형 몇 개를 만들고 넓이를 계산해 봐요. 각 도형에 맞는 공식을 써 주는 것은 Julia의 디스패치 메커니즘에 맡기면 돼요.

julia> circle = Circle(2.3)
Circle(2.3)

julia> area(circle)  # call signature Tuple{Circle}
16.619025137490002

julia> square = Square(1.6)
Square(1.6)

julia> area(square)  # call signature Tuple{Square}
2.5600000000000005

julia> rectangle = Rectangle(1.4, 2.1)
Rectangle(1.4, 2.1)

julia> area(rectangle)  # call signature Tuple{Rectangle}
2.94

파라메트릭 메서드

메서드 시그니처는 파라메트릭 타입과 거의 비슷한 방식으로, 더미 타입 값을 받을 수 있어요.

문법이 독특한데, where 절이 필요해요:

julia> same_type(v::Vector{T}, x::T) where {T} = true
same_type (generic function with 1 method)

julia> same_type([1, 2], 3)
true

julia> same_type([1, 2], "three")
ERROR: MethodError: no method matching same_type(::Vector{Int64}, ::String)

위 예제에서는 두 인자가 같은 타입 T를 사용해야 해요. 그 T가 무엇이든 말이죠.

T에 제약을 걸면 더 구체적으로 만들 수 있어요:

julia> same_type(v::Vector{T}, x::T) where {T<:Number} = true
same_type (generic function with 2 methods)

이 메서드에서는 숫자 타입이 서로 같을 때만 true를 반환해요.

더미 변수는 여러 개를 쓸 수 있고, where 절에서 쉼표로 구분해요.

julia> myfunc(a::S, b::T) where {S, T} = true
myfunc (generic function with 1 method)

정리

다중 디스패치는 Julia에서 매우 유연하고 성능도 뛰어나서 아주 폭넓게 쓰여요.

+에 메서드가 161개 있다는 예에서 알 수 있듯이, Julia 프로그래머들은 타입을 좁게 지정한 메서드를 정의하려고 해요. 바이트코드 수준에서 정수 두 개를 더하는 것과 실수 두 개를 더하는 것은 아주 다르고, 정수에 실수를 더하면 타입 프로모션이라는 복잡한 문제가 하나 더 생겨요.

타입이 구체적일수록 컴파일러가 할 수 있는 최적화가 많아져요. Exercism의 장난감 같은 연습 문제에서는 별로 중요하지 않지만, Julia가 겨냥한 대규모 수치 시뮬레이션에서는 성능에 결정적이에요.

메서드가 어떤 입력을 받을지 안다면, 컴파일러에게 꼭 알려 주세요!

지침

이 연습 문제에서는 만남 시뮬레이션을 구현해요. 이를 통해 Julia의 핵심 패러다임인 다중 디스패치의 기초를 익힐 수 있어요.

For this exercise, all function definitions should be a single-line declaration with no if/else logic.

일반적으로 만남이란 한 개체 a가 다른 개체 b를 만나 반응하는 것을 말해요.

encounter(a, b) = "$(name(a)) meets $(name(b)) and $(meets(a, b))."

먼저 고양이와 개가 만나면 무슨 일이 일어나는지 시뮬레이션해 봐요.

1. 사용자 정의 타입 정의하기

먼저 추상 타입 Pet을 추가해요.

그다음 Cat과 Dog 타입을 Pet의 하위 타입으로 정의해요. 각 타입에는 name 필드가 하나씩 있어요.

2. 반려동물의 이름 알아내기

반려동물의 이름을 반환하는 name() 함수를 구현해요.

julia> fido = Dog("Fido")
Dog("Fido")

julia> name(fido)
"Fido"

3. 고양이와 개가 만나면 벌어지는 일 정의하기

다음 만남에 대한 meets() 메서드를 구현해요:

  • 개 두 마리가 만나면 서로 sniff 해요.
  • 고양이가 개를 만나면 개에게 hisses 해요.
  • 개가 고양이를 만나면 고양이를 chases 해요.
  • 고양이 두 마리가 만나면 slink 해요.
julia> ginger = Cat("Ginger")
Cat("Ginger")

julia> meets(ginger, fido)
"hisses"

4. 두 개체 사이의 만남 정의하기

이름이 있는 두 개체가 만나면 어떤 일이 일어나는지 알려주도록, encounter() 함수를 메서드 하나로 구현해요.

julia> encounter(ginger, fido)
"Ginger meets Fido and hisses."

5. 반려동물 사이의 만남에 대한 대체 반응 정의하기

산책하다가 말처럼 다른 반려동물을 만나면 어떻게 될까요?

  • 반려동물이 알아보지 못하는 다른 반려동물을 만나면 is cautious 해요.
julia> bella = Horse("Bella")
Horse("Bella")

julia> encounter(fido, bella)
"Fido meets Bella and is cautious."

6. 반려동물이 모르는 것을 만났을 때의 대체 동작 정의하기

반려동물이 만날 수 있는 대상 중에는 반려동물이 아닌 것도 많아요. 자동차, 사람, 식물, 자연재해, 소행성… 그럼 그때는 어떻게 될까요?

  • 반려동물이 한 번도 본 적 없는 것을 만나면 runs away 해요.
julia> colnago = Bicycle("Colnago")
Bicycle("Colnago")

julia> encounter(ginger, colnago)
"Ginger meets Colnago and runs away."

7. 일반적인 대체 동작 정의하기

우리 시뮬레이션에서는 다른 만남도 얼마든지 일어날 수 있어요. 자동차가 개를 만나기도 하고, 두 전자가 서로 만나 상호작용하기도 하죠…

모든 만남을 미리 다루는 것은 불가능하니, 일반적인 대체 동작을 구현해 볼게요.

  • 알지 못하는 두 사물이나 존재가 서로 만나면 nothing happens 해요.
julia> γ1 = Photon("γ1")
Photon("γ1")

julia> γ2 = Photon("γ2")
Photon("γ2")

julia> encounter(γ1, γ2)
"γ1 meets γ2 and nothing happens." 
# This is true, photons just pass through each other.
GitHub에서 편집 링크가 새 창이나 탭에서 열려요
Julia Exercism

조우 문제를 시작해 볼 준비가 됐나요?

Exercism에 가입하고 Julia 트랙을 개념 35개연습 문제 128개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.