변환과 승격
변환과 승격 (Conversion and Promotion)
수학 연산자의 인자들을 공통 타입으로 승격하는 시스템이 Julia에 있어요. 앞서 정수와 부동소수점 수, 수학 연산과 기초 함수, 타입, 메서드 같은 여러 절에서 이 시스템을 잠깐씩 언급했었죠. 이번 절에서는 이 승격 시스템이 어떻게 동작하는지, 그리고 이걸 새 타입으로 확장하고 내장 수학 연산자 말고 다른 함수에도 적용하는 방법을 설명할게요. 전통적으로 프로그래밍 언어는 산술 인자의 승격을 놓고 두 갈래로 나뉩니다.
- 내장 산술 타입과 연산자에 대한 자동 승격. 대부분의 언어에서 내장 숫자 타입은
+,-,*,/같은 중위 문법의 산술 연산자에 피연산자로 쓰일 때 자동으로 공통 타입으로 승격되어 기대하는 결과를 냅니다. C, Java, Perl, Python만 해도1 + 1.5라는 합을 부동소수점 값인2.5로 정확히 계산해 주죠.+의 피연산자 중 하나는 정수인데도 말이에요. 이런 시스템은 편리하고 꼼꼼하게 설계되어 프로그래머에게는 거의 눈에 보이지 않습니다. 그런 표현을 쓸 때 누가 승격이 일어난다고 의식하진 않잖아요. 다만 정수와 부동소수점 값은 그대로는 더할 수 없으므로 컴파일러와 인터프리터는 덧셈 전에 반드시 변환을 수행해야 해요. 그래서 이런 자동 변환을 위한 복잡한 규칙이 그런 언어들의 명세와 구현에 어쩔 수 없이 포함되는 겁니다. - 자동 승격이 없는 경우. 이쪽에는 Ada와 ML, 아주 "엄격한" 정적 타입 언어들이 속해요. 이런 언어에서는 모든 변환을 프로그래머가 명시적으로 지정해야 합니다. 그래서 아까 예로 든
1 + 1.5는 Ada와 ML 둘 다에서 컴파일 오류가 되죠. 대신real(1) + 1.5라고 써서 덧셈을 하기 전에 정수1을 부동소수점 값으로 명시적으로 변환해야 해요. 그런데 모든 곳에서 명시적 변환을 요구하는 건 너무 불편해서, 심지어 Ada도 자동 변환을 어느 정도 허용합니다. 정수 리터럴은 기대되는 정수 타입으로, 부동소수점 리터럴은 적절한 부동소수점 타입으로 자동 승격되는 식이죠.
어떤 의미에서 Julia는 "자동 승격 없음" 부류에 속해요. 수학 연산자는 그냥 특별한 문법을 가진 함수에 불과하고, 함수의 인자는 절대 자동으로 변환되지 않으니까요. 하지만 다양한 인자 타입이 섞인 수학 연산을 적용하는 일을 잘 보면, 이는 다형적 다중 디스패치의 극단적인 한 사례일 뿐입니다. Julia의 디스패치와 타입 시스템이 특히 잘 처리해 내는 영역이에요. 수학 피연산자의 "자동" 승격은 그저 이 디스패치의 특별한 응용으로 나타납니다. Julia에는 수학 연산자를 위한 미리 정의된 포괄(catch-all) 디스패치 규칙이 있는데, 어떤 피연산자 타입 조합에 대한 특정 구현이 없을 때 호출돼요. 이 포괄 규칙은 먼저 사용자 정의 가능한 승격 규칙을 써서 모든 피연산자를 공통 타입으로 승격시키고, 그다음 이제 같은 타입이 된 결과 값들에 대해 해당 연산자의 특수 구현을 호출합니다. 사용자 정의 타입은 다른 타입으로/로부터의 변환 메서드를 정의하고, 다른 타입과 섞였을 때 어떤 타입으로 승격해야 하는지를 정하는 승격 규칙 몇 개만 제공하면 이 승격 시스템에 손쉽게 참여할 수 있어요.
변환(Conversion)
특정 타입 T의 값을 얻는 표준적인 방법은 그 타입의 생성자, T(x)를 호출하는 거예요. 그런데 프로그래머가 명시적으로 요청하지 않아도 값을 한 타입에서 다른 타입으로 변환하는 게 편리한 경우가 있어요. 배열에 값을 할당하는 경우가 한 예죠. A가 Vector{Float64}라면 A[1] = 2는 2를 Int에서 Float64로 자동 변환해서 그 결과를 배열에 저장해야 합니다. 이 작업은 convert 함수를 통해 이뤄져요.
convert 함수는 보통 인자를 두 개 받습니다. 첫 번째는 타입 객체이고, 두 번째는 그 타입으로 변환할 값이에요. 반환되는 값은 주어진 타입의 인스턴스로 변환된 값입니다. 이 함수를 가장 쉽게 이해하는 방법은 직접 실행해 보는 거예요.
julia> x = 12
12
julia> typeof(x)
Int64
julia> xu = convert(UInt8, x)
0x0c
julia> typeof(xu)
UInt8
julia> xf = convert(AbstractFloat, x)
12.0
julia> typeof(xf)
Float64
julia> a = Any[1 2 3; 4 5 6]
2×3 Matrix{Any}:
1 2 3
4 5 6
julia> convert(Array{Float64}, a)
2×3 Matrix{Float64}:
1.0 2.0 3.0
4.0 5.0 6.0
변환이 항상 가능한 건 아닙니다. 그 경우엔 convert가 요청된 변환을 수행하는 방법을 모른다는 뜻의 MethodError가 던져져요.
julia> convert(AbstractFloat, "foo")
ERROR: MethodError: Cannot `convert` an object of type String to an object of type AbstractFloat
[...]
어떤 언어는 문자열을 숫자로 파싱하거나 숫자를 문자열로 포매팅하는 것도 변환으로 여깁니다. 많은 동적 언어가 심지어 그런 변환을 자동으로 해 주기도 하죠. 하지만 Julia는 그렇지 않아요. 어떤 문자열은 숫자로 파싱될 수 있지만, 대부분의 문자열은 숫자의 유효한 표현이 아니고, 유효한 것도 극히 일부에 불과합니다. 그래서 Julia에서는 이 작업을 더 명시적으로 만들기 위해 전용 parse 함수를 반드시 사용해야 해요.
convert가 언제 호출될까?
다음과 같은 언어 구조가 convert를 호출합니다.
- 배열에 할당하면 배열의 원소 타입으로 변환돼요.
- 객체의 필드에 할당하면 필드의 선언된 타입으로 변환돼요.
new로 객체를 생성하면 객체의 선언된 필드 타입으로 변환돼요.- 선언된 타입을 가진 변수(
local x::T같은)에 할당하면 그 타입으로 변환돼요. - 선언된 반환 타입을 가진 함수는 반환 값을 그 타입으로 변환해요.
ccall에 값을 넘기면 해당 인자 타입으로 변환돼요.
변환 vs 생성
convert(T, x)의 동작이 T(x)와 거의 동일해 보인다는 점을 기억하세요. 사실 대부분 그렇습니다. 하지만 핵심적인 의미 차이가 하나 있어요. convert는 암시적으로 호출될 수 있기 때문에, 그 메서드들은 "안전하다" 또는 "놀랍지 않다"고 여겨지는 경우로 제한됩니다. convert는 같은 종류의 기본적인 것을 표현하는 타입 사이에서만 변환해요. 예를 들어 서로 다른 숫자 표현, 또는 서로 다른 문자열 인코딩 사이에서 말이죠. 보통 무손실이기도 합니다. 값을 다른 타입으로 변환했다가 다시 되돌리면 정확히 같은 값을 얻어야 해요.
생성자가 convert와 달라지는 일반적인 경우는 크게 네 가지가 있습니다.
인자와 무관한 타입의 생성자
어떤 생성자는 "변환"이라는 개념을 구현하지 않아요. 예를 들어 Timer(2)는 2초짜리 타이머를 만드는데, 이는 정수에서 타이머로의 "변환"이라 부르기 어렵죠.
가변 컬렉션
convert(T, x)는 x가 이미 타입 T라면 원래 x를 그대로 반환할 것으로 기대됩니다. 반면 T가 가변 컬렉션 타입이라면 T(x)는 언제나 새 컬렉션을 만들어야 해요. x의 원소들을 복사해서요.
래퍼 타입
다른 값을 "감싸는" 어떤 타입의 경우, 생성자는 인자가 이미 요청된 타입이어도 그 인자를 새 객체 안에 감쌀 수 있어요. 예를 들어 Some(x)는 x를 감싸서 값이 존재함을 나타냅니다. 결과가 Some이거나 nothing일 수 있는 맥락에서요. 그런데 x 자체가 Some(y) 객체일 수도 있어요. 그 경우 결과는 Some(Some(y))가 되어 감싸기가 두 겹이 됩니다. 반면 convert(Some, x)는 x가 이미 Some이므로 그냥 x를 반환해요.
자기 타입의 인스턴스를 반환하지 않는 생성자
아주 드문 경우, 생성자 T(x)가 타입 T가 아닌 객체를 반환하는 게 말이 될 수 있어요. 래퍼 타입이 자기 자신의 역인 경우(예: Flip(Flip(x)) === x)나, 라이브러리를 재구성할 때 하위 호환성을 위해 옛 호출 문법을 지원하는 경우에 그럴 수 있죠. 하지만 convert(T, x)는 언제나 타입 T의 값을 반환해야 합니다.
새 변환 정의하기
새 타입을 정의할 때, 처음에는 그 타입을 만드는 모든 방법을 생성자로 정의하세요. 암시적 변환이 유용할 게 분명해지고, 어떤 생성자가 위의 "안전성" 기준을 충족한다면 그때 convert 메서드를 추가할 수 있어요. 이런 메서드는 보통 아주 단순합니다. 적절한 생성자를 호출하기만 하면 되니까요. 이런 정의는 대략 이렇게 생겼어요.
import Base: convert
convert(::Type{MyType}, x) = MyType(x)
이 메서드 첫 번째 인자의 타입은 Type{MyType}인데, 그 인스턴스는 MyType 하나뿐입니다. 그래서 이 메서드는 첫 번째 인자가 타입 값 MyType일 때만 호출돼요. 첫 번째 인자에 쓰인 문법을 주목하세요. :: 기호 앞에 인자 이름이 생략되고 타입만 주어졌죠. 이는 타입은 지정됐지만 값은 이름으로 참조할 필요가 없는 함수 인자를 나타내는 Julia 문법입니다.
어떤 추상 타입의 모든 인스턴스는 기본적으로 "충분히 비슷하다"고 간주되어, Julia Base가 보편적인 convert 정의를 제공해요. 예를 들어 다음 정의는 Number 타입을 1-인자 생성자를 호출해서 다른 Number 타입으로 convert해도 유효하다고 선언합니다.
convert(::Type{T}, x::Number) where {T<:Number} = T(x)::T
즉, 새 Number 타입은 생성자만 정의하면 됩니다. 이 정의가 convert를 처리해 주니까요. 인자가 이미 요청된 타입인 경우를 처리하기 위한 항등 변환도 제공돼요.
convert(::Type{T}, x::T) where {T<:Number} = x
AbstractString, AbstractArray, AbstractDict에도 비슷한 정의가 있습니다.
승격(Promotion)
승격은 혼합 타입의 값들을 하나의 공통 타입으로 변환하는 것을 말해요. 엄밀히 필수는 아니지만, 값들이 변환되는 공통 타입이 모든 원래 값을 충실하게 표현할 수 있다는 게 일반적으로 암시됩니다. 이런 의미에서 "승격"이라는 용어는 적절합니다. 값들이 모든 입력 값을 단일 공통 타입으로 표현할 수 있는 "더 큰" 타입으로 변환되니까요. 다만 이것을 객체지향의 (구조적) 슈퍼타이핑이나 Julia의 추상 슈퍼타입 개념과 혼동하지 않는 게 중요해요. 승격은 타입 계층과는 아무 관련이 없고, 서로 다른 표현 사이의 변환과 모든 관련이 있습니다. 예를 들어 모든 Int32 값은 Float64 값으로도 표현될 수 있지만, Int32는 Float64의 하위 타입이 아니에요.
공통의 "더 큰" 타입으로의 승격은 Julia에서 promote 함수가 수행합니다. 이 함수는 임의 개수의 인자를 받아 같은 개수의 값들의 튜플을 공통 타입으로 변환해서 반환하고, 승격이 불가능하면 예외를 던져요. 승격의 가장 흔한 용도는 숫자 인자들을 공통 타입으로 변환하는 것입니다.
julia> promote(1, 2.5)
(1.0, 2.5)
julia> promote(1, 2.5, 3)
(1.0, 2.5, 3.0)
julia> promote(2, 3//4)
(2//1, 3//4)
julia> promote(1, 2.5, 3, 3//4)
(1.0, 2.5, 3.0, 0.75)
julia> promote(1.5, im)
(1.5 + 0.0im, 0.0 + 1.0im)
julia> promote(1 + 2im, 3//4)
(1//1 + 2//1*im, 3//4 + 0//1*im)
부동소수점 값은 부동소수점 인자 타입 중 가장 큰 타입으로 승격돼요. 정수 값은 정수 인자 타입 중 가장 큰 타입으로 승격됩니다. 타입의 크기는 같은데 부호성이 다르다면 부호 없는 타입이 선택돼요. 정수와 부동소수점 값이 섞이면 모든 값을 담을 만큼 큰 부동소수점 타입으로 승격되고, 정수와 유리수가 섞이면 유리수로, 유리수와 부동소수점이 섞이면 부동소수점으로 승격됩니다. 복소수 값과 실수 값이 섞이면 적절한 종류의 복소수 값으로 승격돼요.
사실 승격을 사용하는 것은 이게 전부입니다. 나머지는 그저 영리한 응용의 문제인데, 가장 대표적인 "영리한" 응용은 +, -, *, / 같은 산술 연산자에 대한 포괄 메서드를 정의하는 것이에요. promotion.jl에 나오는 포괄 메서드 정의 몇 가지를 보겠습니다.
+(x::Number, y::Number) = +(promote(x,y)...)
-(x::Number, y::Number) = -(promote(x,y)...)
*(x::Number, y::Number) = *(promote(x,y)...)
/(x::Number, y::Number) = /(promote(x,y)...)
이 메서드 정의들은 숫자 값의 쌍을 더하고, 빼고, 곱하고, 나누는 더 구체적인 규칙이 없을 때, 값을 공통 타입으로 승격시킨 다음 다시 시도하라고 말합니다. 그리고 그게 다예요. 산술 연산을 위해 숫자를 공통 타입으로 승격시킬 일을 그 밖에 어디서도 신경 쓸 필요 없어요. 자동으로 일어나니까요. promotion.jl에는 그 외 많은 산술·수학 함수에 대한 포괄 승격 메서드 정의도 있지만, 그걸 빼면 Julia Base에서 promote 호출이 필요한 곳은 거의 없습니다. promote가 가장 흔히 쓰이는 곳은 편의를 위해 제공되는 외부 생성자 메서드로, 혼합 타입을 가진 생성자 호출이 필드를 적절한 공통 타입으로 승격시킨 내부 타입에 위임하도록 허용해요. 예를 들어 rational.jl이 다음 외부 생성자 메서드를 제공한다는 걸 떠올려 보세요.
Rational(n::Integer, d::Integer) = Rational(promote(n,d)...)
이 덕분에 다음과 같은 호출이 동작합니다.
julia> x = Rational(Int8(15),Int32(-5))
-3//1
julia> typeof(x)
Rational{Int32}
대부분의 사용자 정의 타입에서는 프로그래머가 생성자 함수에 기대하는 타입을 명시적으로 제공하도록 요구하는 편이 더 좋은 관행이에요. 하지만 때로는, 특히 숫자 문제에서는 승격을 자동으로 하는 게 편리할 수 있습니다.
승격 규칙 정의하기
원칙적으로 promote 함수에 대한 메서드를 직접 정의할 수도 있어요. 하지만 그러면 가능한 모든 인자 타입 순열에 대해 중복된 정의를 아주 많이 작성해야 합니다. 대신 promote의 동작은 promote_rule이라는 보조 함수를 통해 정의돼요. 이 함수에 메서드를 제공하면 되죠. promote_rule은 타입 객체 두 개의 쌍을 받아서 또 다른 타입 객체를 반환하는데, 인자 타입의 인스턴스들이 그 반환된 타입으로 승격됩니다. 즉, 다음 규칙을 정의함으로써
import Base: promote_rule
promote_rule(::Type{Float64}, ::Type{Float32}) = Float64
64비트와 32비트 부동소수점 값이 함께 승격될 때는 64비트 부동소수점으로 승격돼야 한다고 선언하는 겁니다. 승격 타입이 인자 타입 중 하나일 필요는 없어요. 예를 들어 다음 승격 규칙 둘 다 Julia Base에 들어 있습니다.
promote_rule(::Type{BigInt}, ::Type{Float64}) = BigFloat
promote_rule(::Type{BigInt}, ::Type{Int8}) = BigInt
후자의 경우 결과 타입은 BigInt입니다. 임의 정밀도 정수 연산을 위한 정수를 담을 만큼 큰 유일한 타입이 BigInt뿐이니까요. 또 한 가지, promote_rule(::Type{A}, ::Type{B})와 promote_rule(::Type{B}, ::Type{A}) 둘 다 정의할 필요는 없다는 점도 기억하세요. promote_rule이 승격 과정에서 사용되는 방식 덕분에 대칭성이 암시됩니다.
promote_rule은 promote_type이라는 두 번째 함수를 정의하는 구성 요소로 사용돼요. promote_type은 임의 개수의 타입 객체를 받아서, 그 타입의 값들이 promote의 인자로 쓰일 때 승격되어야 하는 공통 타입을 반환합니다. 실제 값이 없이, 어떤 타입들의 값들의 컬렉션이 어떤 타입으로 승격되는지 알고 싶다면 promote_type을 쓰면 됩니다.
julia> promote_type(Int8, Int64)
Int64
우리가 promote_type을 직접 오버로드하지 않는다는 점을 기억하세요. promote_rule을 오버로드하죠. promote_type은 promote_rule을 사용하고 대칭성을 추가합니다. promote_type을 직접 오버로드하면 모호성 오류가 발생할 수 있어요. 우리는 어떻게 승격되어야 하는지를 정의하기 위해 promote_rule을 오버로드하고, 그걸 조회하기 위해 promote_type을 사용합니다.
내부적으로 promote_type은 promote 안에서 쓰여, 승격을 위해 인자 값들이 어떤 타입으로 변환되어야 하는지를 결정해요. 궁금한 독자는 promotion.jl의 코드를 읽어보세요. 이 파일이 약 35줄로 완전한 승격 메커니즘을 정의하고 있습니다.
사례 연구: 유리수 승격
마지막으로, Julia의 유리수 타입에 대한 진행 중인 사례 연구를 마무리할게요. 이 타입은 승격 메커니즘을 비교적 정교하게 사용하며 다음 승격 규칙을 가집니다.
import Base: promote_rule
promote_rule(::Type{Rational{T}}, ::Type{S}) where {T<:Integer,S<:Integer} = Rational{promote_type(T,S)}
promote_rule(::Type{Rational{T}}, ::Type{Rational{S}}) where {T<:Integer,S<:Integer} = Rational{promote_type(T,S)}
promote_rule(::Type{Rational{T}}, ::Type{S}) where {T<:Integer,S<:AbstractFloat} = promote_type(T,S)
첫 번째 규칙은 유리수를 다른 정수 타입과 승격하면, 분자/분모 타입이 그 유리수의 분자/분모 타입과 그 정수 타입의 승격 결과인 유리수 타입으로 승격된다는 뜻이에요. 두 번째 규칙은 같은 논리를 두 종류의 유리수에 적용해서, 각각의 분자/분모 타입의 승격 결과인 유리수를 만들어 냅니다. 세 번째이자 마지막 규칙은 유리수를 부동소수점과 승격하면 분자/분모 타입을 그 부동소수점과 승격한 것과 같은 타입이 된다고 정합니다.
이 몇 개의 승격 규칙은 타입의 생성자 및 숫자를 위한 기본 convert 메서드와 함께, 유리수가 Julia의 다른 모든 숫자 타입 — 정수, 부동소수점 수, 복소수 — 와 완전히 자연스럽게 상호 운용되도록 만들기에 충분합니다. 같은 방식으로 적절한 변환 메서드와 승격 규칙을 제공하면 어떤 사용자 정의 숫자 타입이라도 Julia의 미리 정의된 숫자 타입들과 그만큼 자연스럽게 상호 운용될 수 있어요.
더 알아보기 (Learn more)
- Integers and Floating-Point Numbers — 정수와 부동소수점 수의 표현
- Mathematical Operations and Elementary Functions — 수학 연산과 기초 함수
- Methods — 메서드와 다중 디스패치
- Julia 공식 문서: 변환과 승격