타입
타입 (Types)
타입 시스템은 전통적으로 두 갈래로 나뉘어요. 하나는 프로그램의 모든 표현식이 실행 전에 계산 가능한 타입을 가져야 하는 정적 타입 시스템(static type system) 이고, 다른 하나는 프로그램이 다루는 실제 값이 준비되는 런타임까지 타입에 대해 아무것도 모르는 동적 타입 시스템(dynamic type system) 이에요. 객체 지향은 정적 타입 언어에서 약간의 유연성을 주는데, 컴파일 시점에 값의 정확한 타입을 모르는 상태로도 코드를 작성할 수 있게 해 주기 때문이에요. 서로 다른 타입에 대해 동작할 수 있는 코드를 작성하는 능력을 다형성(polymorphism) 이라고 해요. 고전적인 동적 타입 언어의 모든 코드는 다형적이에요. 값의 타입이 제한되는 경우는 명시적으로 타입을 검사하거나, 객체가 런타임에 연산을 지원하지 못할 때뿐이죠.
Julia의 타입 시스템은 동적이지만, 특정 값이 특정 타입임을 나타낼 수 있게 함으로써 정적 타입 시스템의 장점 일부를 얻어요. 이는 효율적인 코드를 생성하는 데 큰 도움이 되지만, 더 중요하게는 함수 인자의 타입에 따른 메서드 디스패치(method dispatch)를 언어에 깊이 통합할 수 있게 해 줘요. 메서드 디스패치는 메서드(Methods)에서 자세히 다루지만, 그 근간은 여기서 소개하는 타입 시스템이에요.
Julia에서 타입을 생략했을 때의 기본 동작은 값이 어떤 타입이든 허용하는 것이에요. 그래서 명시적으로 타입을 쓰지 않고도 유용한 Julia 함수를 많이 작성할 수 있어요. 하지만 더 많은 표현력이 필요할 때는 이전의 "타입 없는" 코드에 명시적 타입 어노테이션을 점진적으로 도입하기 쉬워요. 어노테이션을 추가하는 데는 세 가지 주요 목적이 있어요. Julia의 강력한 다중 디스패치(multiple dispatch) 메커니즘을 활용하고, 사람이 읽기 쉽게 하고, 프로그래머의 오류를 잡아내는 것이에요.
Julia를 타입 시스템 용어로 설명하자면, 그것은 동적(dynamic) 이고 명목적(nominative) 이며 매개변수화(parametric) 돼 있어요. 제네릭 타입은 매개변수화될 수 있고, 타입 간의 계층 관계는 호환되는 구조에 의해 암시되기보다는 명시적으로 선언돼요. Julia 타입 시스템의 특히 독특한 특징 하나는 구체 타입(concrete type)은 서로 서브타입이 될 수 없다는 거예요. 모든 구체 타입은 최종(final)이며 추상 타입만을 슈퍼타입으로 가질 수 있어요. 처음에는 지나치게 제한적이라고 보일 수 있지만, 놀랍도록 단점이 적고 많은 유익한 결과를 가져와요. 동작(behavior)을 상속할 수 있는 것이 구조(structure)를 상속할 수 있는 것보다 훨씬 중요하며, 둘 다 상속하면 전통적인 객체 지향 언어에서 상당한 어려움이 생기기 때문이에요. 구체 타입에도 추상 서브타입이 있기는 한데, 그 예는 두 가지뿐이에요([Union{}](@ref man-abstract-types)와 [Type{T}](@ref man-typet-type)). 그리고 구체 타입의 추가 서브타입은 선언할 수 없어요.
미리 언급해 둘 다른 높은 수준의 Julia 타입 시스템 측면은 다음과 같아요.
- 객체와 비객체 값 사이의 구분이 없어요. Julia의 모든 값은 단일하고 완전히 연결된 타입 그래프에 속하는 타입을 가진 진짜 객체이며, 그래프의 모든 노드는 타입으로서 동등하게 일급(first-class)이에요.
- "컴파일 타임 타입"이라는 의미 있는 개념이 없어요. 값이 갖는 유일한 타입은 프로그램이 실행될 때의 실제 타입이에요. 객체 지향 언어에서는 정적 컴파일과 다형성의 결합이 이 구분을 중요하게 만들기 때문에, 이것을 "런타임 타입(run-time type)"이라고 불러요.
- 타입을 갖는 것은 값뿐이고 변수는 타입을 갖지 않아요. 변수는 값에 묶인 이름일 뿐이에요. 다만 단순함을 위해 "변수의 타입"이라고 말할 때는 "변수가 가리키는 값의 타입"의 줄임말이라고 생각하면 돼요.
- 추상 타입과 구체 타입 모두 다른 타입으로 매개변수화될 수 있어요. 또한 심볼(symbol),
isbits가true를 반환하는 어떤 타입의 값(본질적으로 C 타입처럼 저장되는 숫자·불리언 같은 것들이나 다른 객체에 대한 포인터가 없는struct), 그리고 그것들의 튜플로도 매개변수화될 수 있어요. 타입 매개변수는 참조되거나 제한될 필요가 없을 때 생략할 수 있어요.
Julia의 타입 시스템은 강력하고 표현력이 풍부하면서도 명확하고 직관적이며 방해되지 않도록 설계됐어요. 많은 Julia 프로그래머는 명시적으로 타입을 쓰는 코드를 작성할 필요를 전혀 느끼지 못할 수도 있어요. 하지만 어떤 종류의 프로그래밍은 선언된 타입을 쓰면 더 명확하고, 단순하고, 빠르고, 견고해져요.
타입 선언 (Type Declarations)
:: 연산자는 프로그램 안의 표현식과 변수에 타입 어노테이션을 붙이는 데 사용할 수 있어요. 이렇게 하는 데는 두 가지 주된 이유가 있어요.
- 프로그램이 기대한 대로 동작하는지 확인하는 단언(assertion)으로서, 그리고
- 컴파일러에 추가 타입 정보를 제공해서, 어떤 경우에는 성능을 개선하기 위해서예요.
값을 계산하는 표현식에 붙이면 :: 연산자는 "의 인스턴스다(is an instance of)"로 읽혀요. 어디서든 왼쪽 표현식의 값이 오른쪽 타입의 인스턴스임을 단언하는 데 쓸 수 있어요. 오른쪽 타입이 구체 타입일 때, 왼쪽 값은 반드시 그 타입을 구현으로 가져야 해요 — 모든 구체 타입은 최종이므로 어떤 구현도 다른 구현의 서브타입이 될 수 없다는 것을 기억하세요. 타입이 추상일 때는, 그 값이 추상 타입의 서브타입인 구체 타입으로 구현되면 충분해요. 타입 단언이 참이 아니면 예외가 던져지고, 그렇지 않으면 왼쪽 값이 반환돼요.
julia> (1+2)::AbstractFloat
ERROR: TypeError: in typeassert, expected AbstractFloat, got a value of type Int64
julia> (1+2)::Int
3
이렇게 하면 타입 단언을 어떤 표현식에도 제자리에 붙일 수 있어요.
할당의 왼쪽 변수에 붙이거나 local 선언의 일부로 쓰면, :: 연산자는 조금 다르게 동작해요. C 같은 정적 타입 언어의 타입 선언처럼, 그 변수가 항상 지정된 타입을 갖도록 선언하는 거예요. 변수에 할당되는 모든 값은 convert를 사용해 선언된 타입으로 변환돼요.
julia> function foo()
x::Int8 = 100
x
end
foo (generic function with 1 method)
julia> x = foo()
100
julia> typeof(x)
Int8
이 기능은 변수에 대한 할당 중 하나가 예상치 못하게 타입을 바꿔서 성능 "함정(gotcha)"이 발생하는 것을 피하는 데 유용해요.
이 "선언" 동작은 특정 문맥에서만 발생해요.
local x::Int8 # in a local declaration
x::Int8 = 10 # as the left-hand side of an assignment
그리고 현재 스코프 전체에, 심지어 선언 이전에도 적용돼요.
Julia 1.8부터 타입 선언은 전역 스코프에서도 사용할 수 있어요. 즉 전역 변수에 타입 어노테이션을 추가해서 접근을 타입 안정적으로 만들 수 있어요.
julia> x::Int = 10
10
julia> x = 3.5
ERROR: InexactError: Int64(3.5)
julia> function foo(y)
global x = 15.8 # throws an error when foo is called
return x + y
end
foo (generic function with 1 method)
julia> foo(10)
ERROR: InexactError: Int64(15.8)
선언은 함수 정의에도 붙일 수 있어요.
function sinc(x)::Float64
if x == 0
return 1
end
return sin(pi*x)/(pi*x)
end
이 함수에서 반환하는 것은 선언된 타입을 가진 변수에 할당하는 것과 똑같이 동작해요. 값은 항상 Float64로 변환돼요.
추상 타입 (Abstract Types)
추상 타입은 인스턴스화될 수 없고, 타입 그래프의 노드로만 존재해요. 따라서 관련된 구체 타입들의 집합 — 즉 그 추상 타입의 자손인 구체 타입들 — 을 기술해요. 추상 타입은 인스턴스가 없음에도 먼저 다루는데, 그것이 타입 시스템의 척추이기 때문이에요. 추상 타입은 Julia의 타입 시스템이 단순한 객체 구현의 모음 이상이 되게 하는 개념적 계층을 형성해요.
정수와 부동소수점 수(Integers and Floating-Point Numbers)에서 다양한 수치 값의 구체 타입을 소개했던 것을 떠올려 보세요. Int8, UInt8, Int16, UInt16, Int32, UInt32, Int64, UInt64, Int128, UInt128, Float16, Float32, Float64가 있었죠. 표현 크기는 다르지만 Int8, Int16, Int32, Int64, Int128은 모두 부호 있는 정수 타입이라는 공통점이 있어요. 마찬가지로 UInt8, UInt16, UInt32, UInt64, UInt128은 모두 부호 없는 정수 타입이고, Float16, Float32, Float64는 정수가 아니라 부동소수점 타입이라는 점에서 구별돼요. 예를 들어 어떤 코드는 인자가 어떤 종류의 정수일 때만 말이 되는 경우가 흔해요. 하지만 정확히 어떤 종류의 정수인지에는 의존하지 않죠. 예를 들어 최대공약수 알고리즘은 모든 종류의 정수에서 동작하지만, 부동소수점 수에서는 동작하지 않아요. 추상 타입은 타입의 계층을 구성하게 해 주고, 그 안에 구체 타입이 들어맞을 수 있는 문맥을 제공해요. 이를 통해 예를 들어 알고리즘을 특정 정수 타입에 제한하지 않고, 정수인 어떤 타입에 대해서도 쉽게 프로그래밍할 수 있어요.
추상 타입은 abstract type 키워드로 선언돼요. 추상 타입을 선언하는 일반적인 구문은 다음과 같아요.
abstract type «name» end
abstract type «name» <: «supertype» end
abstract type 키워드는 이름이 «name»인 새 추상 타입을 도입해요. 이 이름 뒤에는 선택적으로 <:와 이미 존재하는 타입이 올 수 있는데, 새로 선언된 추상 타입이 이 "부모" 타입의 서브타입임을 나타내요.
슈퍼타입이 주어지지 않으면 기본 슈퍼타입은 Any예요. Any는 모든 객체가 인스턴스이고 모든 타입이 서브타입인 미리 정의된 추상 타입이죠. 타입 이론에서 Any는 타입 그래프의 정점(apex)에 있기 때문에 보통 "top"이라고 불러요. Julia는 또한 타입 그래프의 최저점(nadir)에 있는 미리 정의된 추상 "bottom" 타입을 갖는데, Union{}으로 써요. 그것은 Any의 정확한 반대예요. 어떤 객체도 Union{}의 인스턴스가 아니고, 모든 타입(구체 타입 포함)이 Union{}의 슈퍼타입이에요.
Julia의 수치 계층을 구성하는 추상 타입 몇 가지를 살펴볼게요.
abstract type Number end
abstract type Real <: Number end
abstract type AbstractFloat <: Real end
abstract type Integer <: Real end
abstract type Signed <: Integer end
abstract type Unsigned <: Integer end
Number 타입은 Any의 직접 자식 타입이고, Real은 그 자식이에요. 차례로 Real은 두 자식을 갖는데(더 있지만 여기선 두 개만 보여드려요. 나머지는 나중에 다룰게요) Integer와 AbstractFloat가 그것이에요. 이 둘은 세상을 정수의 표현과 실수의 표현으로 나눠요. 실수의 표현에는 부동소수점 타입이 포함되지만, 유리수(rational) 같은 다른 타입도 포함돼요. AbstractFloat는 실수의 부동소수점 표현만 포함해요. 정수는 다시 Signed와 Unsigned 변종으로 세분돼요.
<: 연산자는 일반적으로 "의 서브타입이다(is a subtype of)"를 뜻하며, 위와 같은 선언에서 쓰이면 오른쪽 타입이 새로 선언된 타입의 즉시 슈퍼타입임을 선언해요. 표현식에서는 서브타입 연산자로도 쓸 수 있는데, 왼쪽 피연산자가 오른쪽 피연산자의 서브타입이면 true를 반환해요.
julia> Integer <: Number
true
julia> Integer <: AbstractFloat
false
추상 타입의 중요한 용도는 구체 타입에 기본 구현을 제공하는 것이에요. 간단한 예를 들어 볼게요.
function myplus(x,y)
x+y
end
먼저 주목할 점은 위 인자 선언이 x::Any와 y::Any와 동등하다는 거예요. 이 함수를 myplus(2,5)처럼 호출하면 디스패처는 주어진 인자와 일치하는 myplus라는 이름의 가장 구체적인 메서드를 골라요. (다중 디스패치에 대한 자세한 내용은 메서드(Methods)를 참고하세요.)
위보다 더 구체적인 메서드가 없다고 가정하면, Julia는 내부적으로 주어진 제네릭 함수를 바탕으로 두 Int 인자에 특화된 myplus라는 메서드를 정의·컴파일해요. 즉 다음을 암묵적으로 정의·컴파일하는 셈이에요.
function myplus(x::Int,y::Int)
x+y
end
그리고 마지막으로 이 특정 메서드를 호출해요.
이렇게 추상 타입은 프로그래머가 제네릭 함수를 작성해서, 그것을 이후에 여러 구체 타입 조합의 기본 메서드로 쓸 수 있게 해 줘요. 다중 디스패치 덕분에 프로그래머는 기본 메서드와 더 구체적인 메서드 중 어느 것을 쓸지 완전히 제어할 수 있어요.
중요한 점 하나는 프로그래머가 인자가 추상 타입인 함수에 의존하더라도 성능 손실이 없다는 거예요. 그 함수는 호출되는 구체 인자 타입의 각 튜플마다 재컴파일되기 때문이에요. (다만 함수 인자가 추상 타입의 컨테이너인 경우에는 성능 문제가 있을 수 있어요. [성능 팁(Performance Tips)](@ref man-performance-abstract-container)을 참고하세요.)
원시 타입 (Primitive Types)
⚠️ 새 복합 타입으로 기존 원시 타입을 감싸는 것이, 자신만의 원시 타입을 정의하는 것보다 거의 항상 더 좋아요.
이 기능은 Julia가 LLVM이 지원하는 표준 원시 타입을 부트스트랩할 수 있게 하기 위해 존재해요. 그것들이 정의되고 나면 더 정의할 이유는 거의 없어요.
원시 타입은 데이터가 단순한 비트(plain old bits)로 이루어진 구체 타입이에요. 원시 타입의 전형적인 예는 정수와 부동소수점 값이에요. 대부분의 언어와 달리 Julia는 고정된 내장 타입 집합만 제공하는 게 아니라, 자신만의 원시 타입을 선언할 수 있게 해 줘요. 사실 표준 원시 타입은 전부 언어 자체에서 정의돼요.
primitive type Float16 <: AbstractFloat 16 end
primitive type Float32 <: AbstractFloat 32 end
primitive type Float64 <: AbstractFloat 64 end
primitive type Bool <: Integer 8 end
primitive type Char <: AbstractChar 32 end
primitive type Int8 <: Signed 8 end
primitive type UInt8 <: Unsigned 8 end
primitive type Int16 <: Signed 16 end
primitive type UInt16 <: Unsigned 16 end
primitive type Int32 <: Signed 32 end
primitive type UInt32 <: Unsigned 32 end
primitive type Int64 <: Signed 64 end
primitive type UInt64 <: Unsigned 64 end
primitive type Int128 <: Signed 128 end
primitive type UInt128 <: Unsigned 128 end
원시 타입을 선언하는 일반적인 구문은 다음과 같아요.
primitive type «name» «bits» end
primitive type «name» <: «supertype» «bits» end
비트 수는 원시 타입의 선언된 논리적 너비(logical width)를 주고, 이름은 새 타입에 이름을 줘요. 원시 타입은 선택적으로 어떤 슈퍼타입의 서브타입으로 선언될 수 있어요. 슈퍼타입이 생략되면 그 타입은 기본적으로 Any를 즉시 슈퍼타입으로 가져요. 따라서 위의 Bool 선언은 불리언 값의 논리적 너비가 8비트이고, Integer를 즉시 슈퍼타입으로 가진다는 뜻이에요. 원시 타입은 계속해서 바이트로 반올림된 저장소(byte-rounded storage)를 사용해요. 그래서 sizeof(T)는 논리적 너비가 8의 배수가 아니어도 저장 크기를 정수 바이트로 올림해요. 선언된 논리적 너비를 조회하려면 Core.bitsizeof(T)를 쓰세요.
바이트가 아닌 원시 너비(non-byte primitive widths)도 허용되지만, 전문가 전용 기능으로 남아 있어요. 그것들은 표준 내장 원시 너비보다 컴파일러·런타임·ABI 버그를 드러낼 가능성이 더 크고, 배열도 packed 비트 레이아웃이 아니라 바이트 반올림 요소 저장소를 사용해요. 따라서 불리언 값은 실제로는 단일 비트만 필요하지만, 의도적으로 이 전문가 전용 시맨틱을 다루는 게 아니라면 내장 8비트 Bool을 쓰는 게 좋아요.
Bool, Int8, UInt8 타입은 모두 동일한 표현을 가져요. 세 가지 모두 8비트 메모리 덩어리이죠. 하지만 Julia의 타입 시스템이 명목적이므로 구조가 동일해도 서로 바꿔 쓸 수 없어요. 이들 사이의 근본적인 차이는 슈퍼타입이 다르다는 거예요. Bool의 직접 슈퍼타입은 Integer, Int8의 것은 Signed, UInt8의 것은 Unsigned예요. Bool, Int8, UInt8 사이의 다른 모든 차이는 동작 — 이 타입의 객체들을 인자로 받았을 때 함수가 어떻게 동작하도록 정의되었는지 — 의 문제예요. 이것이 명목적 타입 시스템이 필요한 이유예요. 구조가 타입을 결정하고 타입이 동작을 결정한다면, Bool이 Int8이나 UInt8과 다르게 동작하게 만드는 것은 불가능할 거예요.
복합 타입 (Composite Types)
복합 타입은 여러 언어에서 레코드(record), 구조체(struct), 객체(object)라고 불려요. 복합 타입은 이름 있는 필드들의 모음이며, 그 인스턴스는 단일 값으로 취급될 수 있어요. 많은 언어에서 복합 타입은 사용자가 정의할 수 있는 유일한 종류의 타입이고, Julia에서도 사용자 정의 타입 중 단연 가장 흔히 쓰여요.
C++, Java, Python, Ruby 같은 주류 객체 지향 언어에서 복합 타입은 그에 연관된 이름 있는 함수도 가지며, 그 조합을 "객체"라고 불러요. Ruby나 Smalltalk 같은 더 순수한 객체 지향 언어에서는 모든 값이 복합이든 아니든 객체예요. C++과 Java를 포함한 덜 순수한 객체 지향 언어에서는 정수·부동소수점 값 같은 일부 값은 객체가 아니지만, 사용자 정의 복합 타입의 인스턴스는 연관된 메서드를 가진 진짜 객체예요. Julia에서 모든 값은 객체이지만, 함수는 그것이 동작하는 객체와 함께 묶여 있지 않아요. 이는 필수적인데, Julia가 다중 디스패치로 함수의 어떤 메서드를 쓸지 선택하기 때문이에요. 즉 메서드를 선택할 때 첫 번째 인자만이 아니라 함수의 모든 인자의 타입을 고려해요 (메서드와 디스패치에 대한 자세한 내용은 메서드(Methods) 참고). 따라서 함수가 첫 번째 인자에만 "속하는" 것은 부적절해요. 각 객체 "내부"에 이름 있는 메서드 뭉치를 두기보다 메서드를 함수 객체로 조직하는 것은 궁극적으로 언어 설계에서 매우 유익한 측면이 돼요.
복합 타입은 struct 키워드로 도입되며, 뒤에 필드 이름 블록이 따라오고, 선택적으로 :: 연산자로 타입을 주석할 수 있어요.
julia> struct Foo
bar
baz::Int
qux::Float64
end
타입 어노테이션이 없는 필드는 기본적으로 Any이며, 따라서 어떤 타입의 값이든 담을 수 있어요.
Foo 타입의 새 객체는 Foo 타입 객체를 함수처럼 필드 값에 적용해서 만들어요.
julia> foo = Foo("Hello, world.", 23, 1.5)
Foo("Hello, world.", 23, 1.5)
julia> typeof(foo)
Foo
타입이 함수처럼 적용될 때 그것을 생성자(constructor) 라고 불러요. 두 생성자가 자동으로 생성되는데, 이것을 기본 생성자(default constructor) 라고 해요. 하나는 어떤 인자든 받아서 convert로 필드의 타입으로 변환하고, 다른 하나는 필드 타입과 정확히 일치하는 인자를 받아요. 둘 다 생성되는 이유는 실수로 기본 생성자를 덮어쓰지 않으면서 새 정의를 추가하기 더 쉽게 하기 위해서예요.
bar 필드는 타입이 제약되지 않으므로 어떤 값이든 됩니다. 하지만 baz의 값은 Int로 변환 가능해야 해요.
julia> Foo((), 23.5, 1)
ERROR: InexactError: Int64(23.5)
Stacktrace:
[...]
fieldnames 함수로 필드 이름 목록을 찾을 수 있어요.
julia> fieldnames(Foo)
(:bar, :baz, :qux)
전통적인 foo.bar 표기법으로 복합 객체의 필드 값에 접근할 수 있어요.
julia> foo.bar
"Hello, world."
julia> foo.baz
23
julia> foo.qux
1.5
struct로 선언된 복합 객체는 불변(immutable) 입니다. 생성 후 수정할 수 없어요. 처음에는 이상해 보일 수 있지만, 여러 이점이 있어요.
- 더 효율적일 수 있어요. 어떤 struct는 배열에 효율적으로 압축될 수 있고, 어떤 경우 컴파일러는 불변 객체 할당을 아예 피할 수 있어요.
- 타입의 생성자가 제공하는 불변 조건(invariant)을 위반하는 것이 불가능해요.
- 불변 객체를 쓰는 코드는 추론하기 더 쉬워요.
불변 객체는 배열 같은 가변 객체를 필드로 포함할 수 있어요. 그렇게 포함된 객체는 여전히 가변으로 남아요. 불변인 것은 객체 자체의 필드뿐이며, 그것들이 다른 객체를 가리키도록 바꿀 수 없는 거예요.
필요한 경우 가변 복합 객체는 mutable struct 키워드로 선언할 수 있는데, 다음 절에서 논의할게요.
불변 구조체의 모든 필드가 구별 불가능(===)하면, 그 필드들을 포함하는 두 불변 값도 구별 불가능해요.
julia> struct X
a::Int
b::Float64
end
julia> X(1, 2) === X(1, 2)
true
복합 타입의 인스턴스가 어떻게 생성되는지에 대해서는 할 말이 훨씬 더 많지만, 그 논의는 매개변수화된 타입(Parametric Types)과 메서드(Methods) 모두에 의존하며 중요하기 때문에 별도 절인 [생성자(Constructors)](@ref man-constructors)에서 다룰게요.
많은 사용자 정의 타입 X에 대해 [Base.broadcastable(x::X) = Ref(x)](@ref man-interfaces-broadcasting) 메서드를 정의해 그 타입의 인스턴스가 [브로드캐스팅](@ref Broadcasting)에서 0차원 "스칼라"로 동작하게 하고 싶을 수 있어요.
가변 복합 타입 (Mutable Composite Types)
복합 타입이 struct 대신 mutable struct로 선언되면, 그 인스턴스는 수정될 수 있어요.
julia> mutable struct Bar
baz
qux::Float64
end
julia> bar = Bar("Hello", 1.5);
julia> bar.qux = 2.0
2.0
julia> bar.baz = 1//2
1//2
필드와 사용자 사이에 추가 인터페이스를 [인스턴스 프로퍼티(Instance Properties)](@ref man-instance-properties)로 제공할 수 있어요. 이는 bar.baz 표기법으로 접근·수정할 수 있는 것을 더 제어하게 해 줘요.
수정(mutation)을 지원하기 위해 그러한 객체는 일반적으로 힙(heap)에 할당되고 안정적인 메모리 주소를 가져요. 가변 객체는 시간에 따라 서로 다른 값을 담을 수 있는 작은 컨테이너 같아서, 오직 그 주소로만 안정적으로 식별할 수 있어요. 반면 불변 타입의 인스턴스는 특정 필드 값들과 연관돼요 — 필드 값만으로 객체에 대해 모든 것을 알 수 있죠. 타입을 가변으로 만들지 결정할 때는, 같은 필드 값을 가진 두 인스턴스가 동일한 것으로 간주될지, 아니면 시간에 따라 독립적으로 바뀔 필요가 있을지 물어보세요. 동일한 것으로 간주된다면 그 타입은 아마 불변이어야 해요.
정리하면, Julia에서 불변성을 정의하는 두 가지 필수 속성이 있어요.
- 불변 타입의 값을 수정하는 것은 허용되지 않아요.
- 비트 타입의 경우, 값의 비트 패턴은 한번 설정되면 절대 변하지 않고 그 값이 비트 타입의 정체성(identity)이라는 뜻이에요.
- 복합 타입의 경우, 필드 값의 정체성이 절대 변하지 않는다는 뜻이에요. 필드가 비트 타입이면 그 비트가 절대 변하지 않는다는 뜻이고, 배열 같은 가변 타입의 값인 필드라면 그 가변 값의 내용 자체는 수정될 수 있어도 필드가 항상 같은 가변 값을 가리킨다는 뜻이에요.
- 불변 타입의 객체는 컴파일러가 자유롭게 복사할 수 있어요. 불변성 때문에 원본 객체와 복사본을 프로그래밍 방식으로 구별하는 것이 불가능하니까요.
- 특히, 정수·부동소수점처럼 충분히 작은 불변 값은 보통 레지스터(또는 스택 할당)로 함수에 전달돼요.
- 반면 가변 값은 힙에 할당되고, 컴파일러가 그렇지 않다는 걸 알 방법이 없다고 확신하는 경우를 제외하고는 힙에 할당된 값에 대한 포인터로 함수에 전달돼요.
그렇지 않으면 가변인 struct의 한두 필드가 불변인 것으로 알려져 있다면, 아래처럼 const를 써서 그 필드들을 불변으로 선언할 수 있어요. 이것은 불변 struct의 최적화 일부(전부는 아님)를 가능하게 하고, const로 표시된 특정 필드에 불변 조건을 강제하는 데 쓸 수 있어요.
compat — Julia 1.8: 가변 struct의 필드에
const로 주석을 다는 것은 최소 Julia 1.8이 필요해요.
julia> mutable struct Baz
a::Int
const b::Float64
end
julia> baz = Baz(1, 1.5);
julia> baz.a = 2
2
julia> baz.b = 2.0
ERROR: setfield!: const field .b of type Baz cannot be changed
[...]
상호 재귀 타입 (Mutually Recursive Types)
Julia의 최상위 스코프는 절차적(procedural)이기 때문에, 파일에서 나중에 정의된 타입은 더 일찍 등장한 필드 타입에는 사용할 수 없어요. 이는 타입을 사용되는 순서대로 작성하면 대개 문제가 되지 않지만, 물론 필드 타입 정의에 순환(cycle)이 있으면 이 방식은 동작하지 않아요.
struct Node
edges::Vector{Edge} # Error: Edge not defined
end
struct Edge
from::Node
to::Node
end
typegroup 블록은 그 안에 정의된 모든 struct에 대해 블록의 맨 위에서 임시 변수를 도입하고, 블록 끝에서 그것들을 원자적으로 함께 정의함으로써 이 문제를 해결해요.
julia> typegroup
struct Node
edges::Vector{Edge}
end
struct Edge
from::Node
to::Node
end
end
julia> fieldtype(Node, :edges)
Vector{Edge} (alias for Array{Edge, 1})
참고:
typegroup블록 안에는struct또는mutable struct정의만 허용됩니다. 메서드 정의를 포함한 그 밖의 모든 선언은 허용되지 않아요(내부 생성자 정의는struct정의 안에서 허용되며, 모든 타입이 원자적으로 인스턴스화된 후에 의미상 실행돼요).
compat — Julia 1.14:
typegroup키워드는 최소 Julia 1.14가 필요해요.
선언된 타입 (Declared Types)
앞 절에서 논의한 세 종류의 타입(추상, 원시, 복합)은 사실 모두 밀접하게 관련돼 있어요. 그것들은 같은 핵심 속성을 공유해요.
- 명시적으로 선언돼요.
- 이름을 가져요.
- 명시적으로 선언된 슈퍼타입을 가져요.
- 매개변수를 가질 수 있어요.
이런 공유 속성 때문에 이 타입들은 내부적으로 DataType이라는 같은 개념의 인스턴스로 표현되는데, DataType은 이 타입들 중 어느 것의 타입이기도 해요.
julia> typeof(Real)
DataType
julia> typeof(Int)
DataType
DataType은 추상이거나 구체일 수 있어요. 구체라면 지정된 크기, 저장소 레이아웃, (선택적으로) 필드 이름을 가져요. 따라서 원시 타입은 크기가 0이 아니지만 필드 이름이 없는 DataType이에요. 복합 타입은 필드 이름을 갖거나 비어 있는(크기 0) DataType이에요.
시스템의 모든 구체 값은 어떤 DataType의 인스턴스예요.
타입 유니온 (Type Unions)
타입 유니온은 특수한 추상 타입으로, 그 인자 타입 중 어떤 것의 모든 인스턴스를 객체로 포함해요. 특수한 Union 키워드로 구성돼요.
julia> IntOrString = Union{Int,AbstractString}
Union{Int64, AbstractString}
julia> 1 :: IntOrString
1
julia> "Hello!" :: IntOrString
"Hello!"
julia> 1.0 :: IntOrString
ERROR: TypeError: in typeassert, expected Union{Int64, AbstractString}, got a value of type Float64
많은 언어의 컴파일러는 타입을 추론하기 위한 내부 유니온 구문을 갖고 있어요. Julia는 그것을 프로그래머에게 그대로 노출할 뿐이에요. Julia 컴파일러는 가능한 각 타입에 대한 전문화된 코드를 별도 분기로 생성함으로써, 타입 수가 적은 Union 타입[^1]이 있을 때 효율적인 코드를 생성할 수 있어요.
Union 타입의 특히 유용한 경우는 Union{T, Nothing}이에요. 여기서 T는 어떤 타입이든 될 수 있고, Nothing은 유일한 인스턴스가 객체 nothing인 싱글턴 타입이에요. 이 패턴은 다른 언어의 Nullable, Option 또는 Maybe 타입에 해당하는 Julia 식 표현이에요. 함수 인자나 필드를 Union{T, Nothing}으로 선언하면, 그것을 T 타입의 값으로 설정하거나 값이 없음을 나타내는 nothing으로 설정할 수 있어요. 자세한 내용은 [이 FAQ 항목](@ref faq-nothing)을 참고하세요.
매개변수화된 타입 (Parametric Types)
Julia 타입 시스템의 중요하고 강력한 특징은 그것이 매개변수적(parametric)이라는 거예요. 타입이 매개변수를 받을 수 있어서, 타입 선언이 실제로는 새 타입의 전체 계열 — 매개변수 값의 가능한 각 조합마다 하나 — 을 도입해요. 제네릭 프로그래밍의 어떤 버전을 지원하는 언어는 많아요. 거기서는 데이터 구조와 그것을 다루는 알고리즘을, 실제 관련된 정확한 타입을 지정하지 않고 명시할 수 있어요. 예를 들어 ML, Haskell, Ada, Eiffel, C++, Java, C#, F#, Scala 등 일부 언어에는 어떤 형태의 제네릭 프로그래밍이 존재해요. 이들 중 일부는 진정한 매개변수적 다형성(예: ML, Haskell, Scala)을 지원하고, 다른 것들은 애드혹(ad-hoc), 템플릿 기반 스타일의 제네릭 프로그래밍(예: C++, Java)을 지원해요. 여러 언어에 이렇게 다양한 종류의 제네릭 프로그래밍과 매개변수화된 타입이 있으니, Julia의 매개변수화된 타입을 다른 언어와 비교하려 하지 않고, Julia의 시스템 자체를 설명하는 데 집중할게요. 다만 Julia가 동적 타입 언어라 컴파일 타임에 모든 타입 결정을 할 필요가 없어서, 정적 매개변수 타입 시스템에서 겪는 많은 전통적 어려움을 비교적 쉽게 처리할 수 있다는 점은 언급해 둘게요.
모든 선언된 타입(DataType 계열)은 각 경우에 같은 구문으로 매개변수화될 수 있어요. 다음 순서로 다룰게요. 먼저 매개변수화된 복합 타입, 다음으로 매개변수화된 추상 타입, 마지막으로 매개변수화된 원시 타입이에요.
매개변수화된 복합 타입 (Parametric Composite Types)
타입 매개변수는 타입 이름 바로 뒤에, 중괄호로 둘러싸여 도입돼요.
julia> struct Point{T}
x::T
y::T
end
이 선언은 T 타입의 두 "좌표"를 담는 Point{T}라는 새 매개변수화된 타입을 정의해요. 그렇다면 T는 무엇일까요? 그게 바로 매개변수화된 타입의 요점이에요. T는 어떤 타입이라도 될 수 있어요(실제로는 어떤 비트 타입의 값도 되지만, 여기서는 분명히 타입으로 쓰이죠). Point{Float64}는 Point의 정의에서 T를 Float64로 바꾼 타입과 동등한 구체 타입이에요. 따라서 이 단일 선언은 실제로 무한한 수의 타입을 선언해요. Point{Float64}, Point{AbstractString}, Point{Int64} 등등이 그것이에요. 이 각각은 이제 쓸 수 있는 구체 타입이에요.
julia> Point{Float64}
Point{Float64}
julia> Point{AbstractString}
Point{AbstractString}
타입 Point{Float64}는 좌표가 64비트 부동소수점 값인 점이고, 타입 Point{AbstractString}은 "좌표"가 문자열 객체인 "점"이에요 (자세한 내용은 문자열(Strings) 참고).
Point 자체도 유효한 타입 객체이며, Point{Float64}, Point{AbstractString} 등의 모든 인스턴스를 서브타입으로 포함해요.
julia> Point{Float64} <: Point
true
julia> Point{AbstractString} <: Point
true
당연히 다른 타입들은 그것의 서브타입이 아니에요.
julia> Float64 <: Point
false
julia> AbstractString <: Point
false
T의 값이 다른 구체 Point 타입들은 서로 절대 서브타입이 아니에요.
julia> Point{Float64} <: Point{Int64}
false
julia> Point{Float64} <: Point{Real}
false
⚠️ 이 마지막 점은 매우 중요해요.
Float64 <: Real이지만,Point{Float64} <: Point{Real}는 아닙니다.
즉, 타입 이론 용어로 말하면 Julia의 타입 매개변수는 공변(covariant) (혹은 반변 contravariant) 이기보다는 불변(invariant) 이에요. 여기에는 실용적인 이유가 있어요. Point{Float64}의 어떤 인스턴스든 개념적으로는 Point{Real}의 인스턴스와 같을 수 있지만, 두 타입은 메모리에서 서로 다른 표현을 가져요.
Point{Float64}의 인스턴스는 64비트 값 두 개의 직접적인 쌍으로 간결하고 효율적으로 표현될 수 있어요.Point{Real}의 인스턴스는Real의 인스턴스의 어떤 쌍이든 담을 수 있어야 해요.Real의 인스턴스인 객체는 크기와 구조가 임의일 수 있으므로, 실제로Point{Real}의 인스턴스는 각각 개별적으로 할당된Real객체에 대한 포인터 쌍으로 표현되어야 해요.
Point{Float64} 객체를 즉시 값으로 저장함으로써 얻는 효율성은 배열의 경우 엄청나게 증폭돼요. Array{Float64}는 64비트 부동소수점 값의 연속 메모리 블록으로 저장될 수 있지만, Array{Real}는 개별적으로 할당된 Real 객체에 대한 포인터 배열이어야 해요. 그것들은 박싱(boxed)된 64비트 부동소수점 값일 수도 있지만, Real 추상 타입의 구현으로 선언된 임의로 크고 복잡한 객체일 수도 있어요.
Point{Float64}는 Point{Real}의 서브타입이 아니므로, 다음 메서드는 Point{Float64} 타입의 인자에는 적용될 수 없어요.
function norm(p::Point{Real})
sqrt(p.x^2 + p.y^2)
end
T가 Real의 서브타입인 Point{T} 타입의 인자를 모두 받는 메서드를 정의하는 올바른 방법은 다음과 같아요.
function norm(p::Point{<:Real})
sqrt(p.x^2 + p.y^2)
end
(동등하게, function norm(p::Point{T} where T<:Real) 또는 function norm(p::Point{T}) where T<:Real로 정의할 수도 있어요. UnionAll 타입 참고.)
더 많은 예시는 나중에 메서드(Methods)에서 다룰게요.
Point 객체는 어떻게 구성할까요? 복합 타입에 대해 사용자 정의 생성자를 정의할 수 있는데, 그건 [생성자(Constructors)](@ref man-constructors)에서 자세히 다룰게요. 하지만 특별한 생성자 선언이 없으면 새 복합 객체를 만드는 기본 방법이 두 가지 있어요. 하나는 타입 매개변수를 명시적으로 주는 방식이고, 다른 하나는 객체 생성자의 인자로부터 암시되는 방식이에요.
Point{Float64} 타입은 T 대신 Float64로 선언된 Point와 동등한 구체 타입이므로, 그에 따라 생성자로 적용될 수 있어요.
julia> p = Point{Float64}(1.0, 2.0)
Point{Float64}(1.0, 2.0)
julia> typeof(p)
Point{Float64}
기본 생성자의 경우 각 필드마다 정확히 하나의 인자가 제공되어야 해요.
julia> Point{Float64}(1.0)
ERROR: MethodError: no method matching Point{Float64}(::Float64)
The type `Point{Float64}` exists, but no method is defined for this combination of argument types when trying to construct it.
[...]
julia> Point{Float64}(1.0, 2.0, 3.0)
ERROR: MethodError: no method matching Point{Float64}(::Float64, ::Float64, ::Float64)
The type `Point{Float64}` exists, but no method is defined for this combination of argument types when trying to construct it.
[...]
매개변수화된 타입에는 기본 생성자가 하나만 생성돼요. 그것을 오버라이드할 수 없기 때문이에요. 이 생성자는 어떤 인자든 받아 필드 타입으로 변환해요.
많은 경우 구성하려는 Point 객체의 타입을 제공하는 것은 중복돼요. 생성자 호출의 인자 타입이 이미 타입 정보를 암묵적으로 제공하니까요. 그런 이유로, 매개변수 타입 T의 암시된 값이 모호하지 않다면 Point 자체를 생성자로 적용할 수도 있어요.
julia> p1 = Point(1.0,2.0)
Point{Float64}(1.0, 2.0)
julia> typeof(p1)
Point{Float64}
julia> p2 = Point(1,2)
Point{Int64}(1, 2)
julia> typeof(p2)
Point{Int64}
Point의 경우 T의 타입은 두 인자가 같은 타입일 때에만 모호하지 않게 암시돼요. 그렇지 않으면 생성자는 MethodError로 실패해요.
julia> Point(1,2.5)
ERROR: MethodError: no method matching Point(::Int64, ::Float64)
The type `Point` exists, but no method is defined for this combination of argument types when trying to construct it.
Closest candidates are:
Point(::T, !Matched::T) where T
@ Main none:2
Stacktrace:
[...]
이런 혼합된 경우를 제대로 처리하는 생성자 메서드를 정의할 수 있지만, 그것은 나중에 [생성자(Constructors)](@ref man-constructors)에서 다룰게요.
매개변수화된 추상 타입 (Parametric Abstract Types)
매개변수화된 추상 타입 선언은 거의 같은 방식으로 추상 타입의 모음을 선언해요.
julia> abstract type Pointy{T} end
이 선언으로 Pointy{T}는 T의 각 타입 또는 정수 값마다 구별되는 추상 타입이에요. 매개변수화된 복합 타입과 마찬가지로 각 인스턴스는 Pointy의 서브타입이에요.
julia> Pointy{Int64} <: Pointy
true
julia> Pointy{1} <: Pointy
true
매개변수화된 추상 타입은 매개변수화된 복합 타입과 마찬가지로 불변(invariant)이에요.
julia> Pointy{Float64} <: Pointy{Real}
false
julia> Pointy{Real} <: Pointy{Float64}
false
Pointy{<:Real} 표기법은 공변 타입의 Julia 유사 표현을 나타내고, Pointy{>:Int}는 반변 타입의 유사 표현을 나타내지만, 기술적으로는 이것들이 타입의 집합 을 나타내요 (UnionAll 타입 참고).
julia> Pointy{Float64} <: Pointy{<:Real}
true
julia> Pointy{Real} <: Pointy{>:Int}
true
일반 추상 타입이 구체 타입 위에 유용한 타입 계층을 만드는 역할을 하듯, 매개변수화된 추상 타입은 매개변수화된 복합 타입에 대해 같은 목적을 제공해요. 예를 들어 Point{T}를 Pointy{T}의 서브타입으로 선언할 수 있어요.
julia> struct Point{T} <: Pointy{T}
x::T
y::T
end
그런 선언이 주어지면, 각 T 선택에 대해 Point{T}는 Pointy{T}의 서브타입이 돼요.
julia> Point{Float64} <: Pointy{Float64}
true
julia> Point{Real} <: Pointy{Real}
true
julia> Point{AbstractString} <: Pointy{AbstractString}
true
이 관계도 불변이에요.
julia> Point{Float64} <: Pointy{Real}
false
julia> Point{Float64} <: Pointy{<:Real}
true
Pointy 같은 매개변수화된 추상 타입은 어떤 목적을 제공할까요? 점이 대각선 x = y 상에 있어서 단일 좌표만 필요한 점 같은 구현을 만든다고 생각해 보세요.
julia> struct DiagPoint{T} <: Pointy{T}
x::T
end
이제 Point{Float64}와 DiagPoint{Float64} 둘 다 Pointy{Float64} 추상화의 구현이고, 다른 모든 가능한 타입 T 선택에서도 마찬가지예요. 이를 통해 Point와 DiagPoint 모두에 대해 구현된, 모든 Pointy 객체가 공유하는 공통 인터페이스로 프로그래밍할 수 있어요. 하지만 이는 다음 절인 메서드(Methods)에서 메서드와 디스패치를 소개해야 완전히 시연할 수 있어요.
타입 매개변수가 가능한 모든 타입을 자유롭게 오가게 하는 것이 말이 안 되는 상황이 있어요. 그런 상황에서는 T의 범위를 이렇게 제약할 수 있어요.
julia> abstract type Pointy{T<:Real} end
그런 선언으로 T 자리에 Real의 서브타입인 어떤 타입이든 쓰는 것은 허용되지만, Real의 서브타입이 아닌 타입은 허용되지 않아요.
julia> Pointy{Float64}
Pointy{Float64}
julia> Pointy{Real}
Pointy{Real}
julia> Pointy{AbstractString}
ERROR: TypeError: in Pointy, in T, expected T<:Real, got Type{AbstractString}
julia> Pointy{1}
ERROR: TypeError: in Pointy, in T, expected T<:Real, got a value of type Int64
매개변수화된 복합 타입의 타입 매개변수도 같은 방식으로 제한할 수 있어요.
struct Point{T<:Real} <: Pointy{T}
x::T
y::T
end
이 모든 매개변수 타입 장치가 어떻게 유용할 수 있는지 실제 예를 들기 위해, Julia의 Rational 불변 타입의 실제 정의(단순함을 위해 생성자는 생략했어요)를 보여드릴게요. 이 타입은 정수의 정확한 비율을 나타내요.
struct Rational{T<:Integer} <: Real
num::T
den::T
end
정수 값의 비율을 취하는 것만 말이 되므로 매개변수 타입 T는 Integer의 서브타입으로 제한되고, 정수의 비율은 실수선(real number line)의 값을 나타내므로 어떤 Rational이든 Real 추상화의 인스턴스예요.
튜플 타입 (Tuple Types)
튜플은 함수의 인자에 대한 추상화예요 — 함수 자체는 제외하고요. 함수 인자의 눈에 띄는 측면은 순서와 타입이에요. 따라서 튜플 타입은 각 매개변수가 한 필드의 타입인 매개변수화된 불변 타입과 비슷해요. 예를 들어 2요소 튜플 타입은 다음 불변 타입과 비슷해요.
struct Tuple2{A,B}
a::A
b::B
end
하지만 세 가지 핵심 차이가 있어요.
- 튜플 타입은 임의의 수의 매개변수를 가질 수 있어요.
- 튜플 타입은 매개변수에서 공변(covariant) 이에요.
Tuple{Int}는Tuple{Any}의 서브타입이에요. 따라서Tuple{Any}는 추상 타입으로 간주되고, 튜플 타입은 매개변수가 구체일 때만 구체예요. - 튜플은 필드 이름이 없어요. 필드는 인덱스로만 접근돼요.
튜플 값은 괄호와 쉼표로 써요. 튜플이 구성되면 적절한 튜플 타입이 그때그때(on demand) 생성돼요.
julia> typeof((1,"foo",2.5))
Tuple{Int64, String, Float64}
공변성의 함의를 주목하세요.
julia> Tuple{Int,AbstractString} <: Tuple{Real,Any}
true
julia> Tuple{Int,AbstractString} <: Tuple{Real,Real}
false
julia> Tuple{Int,AbstractString} <: Tuple{Real,}
false
직관적으로 이것은 함수 인자의 타입이 함수의 시그니처의 서브타입이라는 것에 해당해요 (시그니처가 일치할 때).
가변 인자 튜플 타입 (Vararg Tuple Types)
튜플 타입의 마지막 매개변수는 특수한 값 Vararg일 수 있으며, 임의의 수의 후행 요소를 나타내요.
julia> mytupletype = Tuple{AbstractString,Vararg{Int}}
Tuple{AbstractString, Vararg{Int64}}
julia> isa(("1",), mytupletype)
true
julia> isa(("1",1), mytupletype)
true
julia> isa(("1",1,2), mytupletype)
true
julia> isa(("1",1,2,3.0), mytupletype)
false
더 나아가 Vararg{T}는 타입 T의 요소 0개 이상에 해당해요. 가변 인자 튜플 타입은 가변 인자 메서드(varargs methods)가 받는 인자를 나타내는 데 사용돼요 (자세한 내용은 가변 인자 함수(Varargs Functions) 참고).
특수한 값 Vararg{T,N}(튜플 타입의 마지막 매개변수로 쓰일 때)은 타입 T의 정확히 N개 요소에 해당해요. NTuple{N,T}는 Tuple{Vararg{T,N}}의 편리한 별칭이에요. 즉 타입 T의 정확히 N개 요소를 포함하는 튜플 타입이죠.
이름 있는 튜플 타입 (Named Tuple Types)
이름 있는 튜플(named tuple)은 NamedTuple 타입의 인스턴스예요. NamedTuple은 두 매개변수를 가져요. 필드 이름을 주는 심볼 튜플과 필드 타입을 주는 튜플 타입이에요. 편의를 위해 NamedTuple 타입은 @NamedTuple 매크로로 출력되는데, 이 매크로는 key::Type 선언으로 이 타입들을 선언하는 편리한 struct-같은 구문을 제공해요. 생략된 ::Type은 ::Any에 해당해요.
julia> typeof((a=1,b="hello")) # prints in macro form
@NamedTuple{a::Int64, b::String}
julia> NamedTuple{(:a, :b), Tuple{Int64, String}} # long form of the type
@NamedTuple{a::Int64, b::String}
@NamedTuple 매크로의 begin ... end 형식은 구조체 선언처럼 선언을 여러 줄로 나눌 수 있게 하지만, 그 외에는 동일해요.
julia> @NamedTuple begin
a::Int
b::String
end
@NamedTuple{a::Int64, b::String}
NamedTuple 타입은 생성자로 사용될 수 있고, 단일 튜플 인자를 받아요. 구성된 NamedTuple 타입은 두 매개변수가 모두 지정된 구체 타입이거나, 필드 이름만 지정하는 타입일 수 있어요.
julia> @NamedTuple{a::Float32,b::String}((1, ""))
(a = 1.0f0, b = "")
julia> NamedTuple{(:a, :b)}((1, ""))
(a = 1, b = "")
필드 타입이 지정되면 인자가 변환돼요. 그렇지 않으면 인자의 타입이 그대로 사용돼요.
매개변수화된 원시 타입 (Parametric Primitive Types)
원시 타입도 매개변수적으로 선언될 수 있어요. 예를 들어 포인터는 Julia에서 다음과 같이 선언되는 원시 타입으로 표현돼요.
# 32-bit system:
primitive type Ptr{T} 32 end
# 64-bit system:
primitive type Ptr{T} 64 end
전형적인 매개변수화된 복합 타입과 비교해서 이 선언들의 약간 이상한 특징은, 타입 매개변수 T가 타입 자체의 정의에 사용되지 않는다는 거예요. T는 단지 추상적인 태그(tag)로, 본질적으로 동일한 구조를 가진, 오직 타입 매개변수로만 구별되는 전체 타입 계열을 정의해요. 따라서 Ptr{Float64}와 Ptr{Int64}는 동일한 표현을 가짐에도 서로 다른 타입이에요. 물론 모든 특정 포인터 타입은 우산(umbrella) Ptr 타입의 서브타입이에요.
julia> Ptr{Float64} <: Ptr
true
julia> Ptr{Int64} <: Ptr
true
UnionAll 타입
Ptr 같은 매개변수화된 타입이 모든 인스턴스(Ptr{Int64} 등)의 슈퍼타입으로 동작한다고 말했어요. 어떻게 이게 동작할까요? Ptr 자체는 정상 데이터 타입일 수 없어요. 참조된 데이터의 타입을 모르면 그 타입을 메모리 연산에 쓸 수 없으니까요. 정답은 Ptr(또는 Array 같은 다른 매개변수화된 타입)이 UnionAll 타입이라는 다른 종류의 타입이라는 거예요. 그런 타입은 어떤 매개변수의 모든 값에 대한 타입의 반복된 합(iterated union) 을 표현해요.
UnionAll 타입은 보통 where 키워드로 써요. 예를 들어 Ptr은 Ptr{T} where T로 더 정확히 쓸 수 있는데, 이는 어떤 T 값에 대해 타입이 Ptr{T}인 모든 값을 뜻해요. 이 문맥에서 매개변수 T는 타입에 걸쳐 있는 변수 같아서 "타입 변수(type variable)"라고도 불러요. 각 where는 단일 타입 변수를 도입하므로, 여러 매개변수를 가진 타입에서는 이 표현들이 중첩돼요. 예를 들어 Array{T,N} where N where T처럼요.
타입 적용 구문 A{B,C}는 A가 UnionAll 타입일 것을 요구하고, 먼저 B를 A의 가장 바깥 타입 변수에 치환해요. 그 결과는 또 다른 UnionAll 타입일 것으로 기대되며, 거기에 C가 치환돼요. 그래서 A{B,C}는 A{B}{C}와 동등해요. 이것이 Array{Float64}처럼 타입을 부분적으로 인스턴스화하는 것이 가능한 이유를 설명해요. 첫 번째 매개변수 값은 고정됐지만, 두 번째는 여전히 모든 가능한 값에 걸쳐 있어요. 명시적 where 구문을 쓰면 매개변수의 어떤 부분집합이든 고정할 수 있어요. 예를 들어 모든 1차원 배열의 타입은 Array{T,1} where T로 쓸 수 있어요.
타입 변수는 서브타입 관계로 제한될 수 있어요. Array{T} where T<:Integer는 요소 타입이 어떤 종류의 Integer인 모든 배열을 가리켜요. Array{<:Integer} 구문은 Array{T} where T<:Integer의 편리한 축약형이에요. 타입 변수는 하한과 상한을 모두 가질 수 있어요. Array{T} where Int<:T<:Number는 Int를 담을 수 있는 모든 Number 배열을 가리켜요 (T는 Int보다 최소한 커야 하니까요). 타입 변수의 하한만 지정하는 where T>:Int 구문도 동작하며, Array{>:Int}는 Array{T} where T>:Int와 동등해요.
where 표현은 중첩되므로 타입 변수 경계가 바깥 타입 변수를 참조할 수 있어요. 예를 들어 Tuple{T,Array{S}} where S<:AbstractArray{T} where T<:Real은 첫 요소가 어떤 Real이고, 두 요소가 첫 튜플 요소의 타입을 요소 타입으로 포함하는 어떤 종류의 배열인 Array인 2-튜플을 가리켜요.
where 키워드 자체는 더 복잡한 선언 안에 중첩될 수 있어요. 예를 들어 다음 선언들로 만들어진 두 타입을 고려해 보세요.
julia> const T1 = Array{Array{T, 1} where T, 1}
Vector{Vector} (alias for Array{Array{T, 1} where T, 1})
julia> const T2 = Array{Array{T, 1}, 1} where T
Vector{Vector{T}} where T (alias for Array{Array{T, 1}, 1} where T)
타입 T1은 1차원 배열들의 1차원 배열을 정의해요. 각 안쪽 배열은 같은 타입의 객체들로 구성되지만, 그 타입은 안쪽 배열마다 달라질 수 있어요. 반면 타입 T2는 1차원 배열들의 1차원 배열로, 모든 안쪽 배열이 반드시 같은 타입을 가져야 해요. T2는 추상 타입인 반면(예: Array{Array{Int,1},1} <: T2), T1은 구체 타입이라는 점을 주목하세요. 결과적으로 T1은 인자 없는 생성자 a=T1()로 구성될 수 있지만 T2는 그럴 수 없어요.
그런 타입에 이름을 붙이는 편리한 구문이 있는데, 함수 정의 구문의 짧은 형식과 비슷해요.
Vector{T} = Array{T, 1}
이것은 const Vector = Array{T,1} where T와 동등해요. Vector{Float64}라고 쓰는 것은 Array{Float64,1}라고 쓰는 것과 동등하고, 우산 타입 Vector는 요소 타입과 무관하게 두 번째 매개변수 — 배열 차원 수 — 가 1인 모든 Array 객체를 인스턴스로 가져요. 매개변수화된 타입을 항상 완전히 지정해야 하는 언어에서는 이것이 특별히 유용하지 않지만, Julia에서는 어떤 요소 타입의 1차원 밀집(dense) 배열을 모두 포함하는 추상 타입으로 Vector만 쓸 수 있게 해 줘요.
싱글턴 타입 (Singleton types)
필드가 없는 불변 복합 타입을 싱글턴(singleton) 이라고 불러요. 형식적으로,
T가 불변 복합 타입(즉struct로 정의된)이고,a isa T && b isa T이면a === b라는 뜻이면,
T는 싱글턴 타입이에요.[^2] Base.issingletontype로 타입이 싱글턴 타입인지 확인할 수 있어요. [추상 타입](@ref man-abstract-types)은 구성상 싱글턴 타입이 될 수 없어요.
정의로부터 그런 타입은 인스턴스가 하나뿐일 수밖에 없다는 게 따라와요.
julia> struct NoFields
end
julia> NoFields() === NoFields()
true
julia> Base.issingletontype(NoFields)
true
=== 함수가 NoFields의 구성된 인스턴스들이 실제로 하나라는 것을 확인해 줘요.
매개변수화된 타입은 위 조건이 성립할 때 싱글턴 타입이 될 수 있어요. 예를 들어,
julia> struct NoFieldsParam{T}
end
julia> Base.issingletontype(NoFieldsParam) # Can't be a singleton type ...
false
julia> NoFieldsParam{Int}() isa NoFieldsParam # ... because it has ...
true
julia> NoFieldsParam{Bool}() isa NoFieldsParam # ... multiple instances.
true
julia> Base.issingletontype(NoFieldsParam{Int}) # Parametrized, it is a singleton.
true
julia> NoFieldsParam{Int}() === NoFieldsParam{Int}()
true
함수의 타입 (Types of functions)
각 함수는 Function의 서브타입인 자신만의 타입을 가져요.
julia> foo41(x) = x + 1
foo41 (generic function with 1 method)
julia> typeof(foo41)
typeof(foo41) (singleton type of function foo41, subtype of Function)
typeof(foo41)이 자기 자신으로 출력되는 방식을 주목하세요. 이것은 단지 출력을 위한 관례예요. 그것은 다른 어떤 값처럼 사용될 수 있는 일급 객체이기 때문이에요.
julia> T = typeof(foo41)
typeof(foo41) (singleton type of function foo41, subtype of Function)
julia> T <: Function
true
최상위에서 정의된 함수의 타입은 싱글턴이에요. 필요할 때 ===로 비교할 수 있어요.
[클로저(Closures)](@ref man-anonymous-functions)도 자신만의 타입을 가지며, 보통 #<number>로 끝나는 이름으로 출력돼요. 서로 다른 위치에서 정의된 함수의 이름과 타입은 구별되지만, 세션에 걸쳐 같은 방식으로 출력된다는 보장은 없어요.
julia> typeof(x -> x + 1)
var"#9#10"
클로저의 타입은 반드시 싱글턴은 아니에요.
julia> addy(y) = x -> x + y
addy (generic function with 1 method)
julia> typeof(addy(1)) === typeof(addy(2))
true
julia> addy(1) === addy(2)
false
julia> Base.issingletontype(typeof(addy(1)))
false
[Type{T} 타입 선택자](@id man-typet-type)
각 타입 T에 대해 Type{T}는 유일한 인스턴스가 객체 T인 추상 매개변수화된 타입이에요. 매개변수화된 메서드(Parametric Methods)와 [변환(conversions)](@ref conversion-and-promotion)을 논의하기 전에는 이 구문의 유용성을 설명하기 어렵지만, 요약하면 특정 타입을 값 으로서 함수 동작을 특화할 수 있게 해 줘요. 이것은 인자 중 하나의 타입에 의해 암시되는 게 아니라 명시적 인자로 주어진 타입에 동작이 의존하는 메서드(특히 매개변수화된 메서드)를 작성하는 데 유용해요.
정의가 조금 파싱하기 어려우니 예시를 몇 개 볼게요.
julia> isa(Float64, Type{Float64})
true
julia> isa(Real, Type{Float64})
false
julia> isa(Real, Type{Real})
true
julia> isa(Float64, Type{Real})
false
즉, isa(A, Type{B})는 A와 B가 같은 객체이고 그 객체가 타입일 때에만 true예요.
특히 매개변수화된 타입은 [불변](@ref man-parametric-composite-types)이므로 다음이 성립해요.
julia> struct TypeParamExample{T}
x::T
end
julia> TypeParamExample isa Type{TypeParamExample}
true
julia> TypeParamExample{Int} isa Type{TypeParamExample}
false
julia> TypeParamExample{Int} isa Type{TypeParamExample{Int}}
true
매개변수 없이 Type은 단순히 모든 타입 객체를 인스턴스로 가진 추상 타입이에요.
julia> isa(Type{Float64}, Type)
true
julia> isa(Float64, Type)
true
julia> isa(Real, Type)
true
타입이 아닌 어떤 객체도 Type의 인스턴스가 아니에요.
julia> isa(1, Type)
false
julia> isa("foo", Type)
false
Type은 다른 추상 매개변수화된 타입처럼 Julia 타입 계층의 일부이지만, 메서드 시그니처 밖에서는 특별한 경우를 제외하고 흔히 쓰이지 않아요. Type의 또 다른 중요한 용도는 그렇지 않으면 덜 정밀하게 포착될 필드 타입을 예리하게(sharpening) 만드는 거예요. 아래 예시에서 기본 생성자가 정밀한 래핑 타입에 의존하는 코드에서 성능 문제를 일으킬 수 있는 [DataType](@ref man-declared-types)로 포착되는 경우가 그 예시예요.
julia> struct WrapType{T}
value::T
end
julia> WrapType(Float64) # default constructor, note DataType
WrapType{DataType}(Float64)
julia> WrapType(::Type{T}) where T = WrapType{Type{T}}(T)
WrapType
julia> WrapType(Float64) # sharpened constructor, note more precise Type{Float64}
WrapType{Type{Float64}}(Float64)
이 Type{Float64} 동작은 추상 타입이 구체 타입(여기서는 DataType)을 서브타입하는 예시예요.
타입 별칭 (Type Aliases)
이미 표현할 수 있는 타입에 새 이름을 도입하는 게 편리할 때가 있어요. 단순한 할당문으로 할 수 있어요. 예를 들어 UInt은 시스템의 포인터 크기에 적합하게 UInt32 또는 UInt64에 별칭됩니다.
# 32-bit system:
julia> UInt
UInt32
# 64-bit system:
julia> UInt
UInt64
이것은 base/boot.jl의 다음 코드로 이뤄져요.
if Int === Int64
const UInt = UInt64
else
const UInt = UInt32
end
물론 이것은 Int가 무엇으로 별칭되는지에 달려 있어요 — 하지만 Int는 미리 정의되어 올바른 타입 — Int32 또는 Int64 — 으로 되어 있어요.
(Int와 달리 Float는 특정 크기의 AbstractFloat에 대한 타입 별칭으로 존재하지 않는다는 점을 주목하세요. Int의 크기가 그 머신의 네이티브 포인터 크기를 반영하는 정수 레지스터와 달리, 부동소수점 레지스터 크기는 IEEE-754 표준에 의해 지정되기 때문이에요.)
타입 별칭은 매개변수화될 수 있어요.
julia> const Family{T} = Set{T}
Set
julia> Family{Char} === Set{Char}
true
타입에 대한 연산 (Operations on Types)
Julia에서 타입 자체가 객체이므로, 일반 함수가 타입에 대해 동작할 수 있어요. 타입을 다루거나 탐구하는 데 특히 유용한 함수 몇 가지는 이미 소개했어요. 왼쪽 피연산자가 오른쪽 피연산자의 서브타입인지 나타내는 <: 연산자 같은 것이죠.
isa 함수는 객체가 주어진 타입인지 검사하고 true 또는 false를 반환해요.
julia> isa(1, Int)
true
julia> isa(1, AbstractFloat)
false
typeof 함수는 매뉴얼 전반의 예시에서 이미 썼는데, 인자의 타입을 반환해요. 위에서 언급했듯 타입은 객체이므로 타입도 타입을 가지며, 그 타입이 무엇인지 물어볼 수 있어요.
julia> typeof(Rational{Int})
DataType
julia> typeof(Union{Real,String})
Union
이 과정을 반복하면 어떨까요? 타입의 타입의 타입은 무엇일까요? 실제로 타입은 모두 복합 값이므로 모두 DataType 타입을 가져요.
julia> typeof(DataType)
DataType
julia> typeof(Union)
DataType
DataType은 자기 자신이 자기 타입이에요.
일부 타입에 적용되는 또 다른 연산은 supertype인데, 타입의 슈퍼타입을 드러내요. 선언된 타입(DataType)만이 모호하지 않은 슈퍼타입을 가져요.
julia> supertype(Float64)
AbstractFloat
julia> supertype(Number)
Any
julia> supertype(AbstractString)
Any
julia> supertype(Any)
Any
supertype을 다른 타입 객체(또는 비타입 객체)에 적용하면 MethodError가 발생해요.
julia> supertype(Union{Float64,Int64})
ERROR: MethodError: no method matching supertype(::Core.TypeEgal{Union{Float64, Int64}})
The function `supertype` exists, but no method is defined for this combination of argument types.
Closest candidates are:
[...]
사용자 정의 pretty-printing (Custom pretty-printing)
종종 타입의 인스턴스가 어떻게 표시되는지 사용자 정의하고 싶을 때가 있어요. 이것은 show 함수를 오버로드해서 이뤄져요. 예를 들어 복소수를 극형식(polar form)으로 나타내는 타입을 정의한다고 해 볼게요.
julia> struct Polar{T<:Real} <: Number
r::T
Θ::T
end
julia> Polar(r::Real,Θ::Real) = Polar(promote(r,Θ)...)
Polar
여기서 서로 다른 Real 타입의 인자를 받아 공통 타입으로 승격(promote)할 수 있도록 사용자 정의 생성자 함수를 추가했어요 (자세한 내용은 [생성자(Constructors)](@ref man-constructors)와 [변환과 승격(Conversion and Promotion)](@ref conversion-and-promotion) 참고). (물론 Number처럼 동작하게 하려면 +, *, one, zero, 승격 규칙 등 많은 다른 메서드도 정의해야 해요.) 기본적으로 이 타입의 인스턴스는 Polar{Float64}(3.0,4.0)처럼 타입 이름과 필드 값에 대한 정보로 아주 단순하게 표시돼요.
이것을 3.0 * exp(4.0im)으로 표시하고 싶다면, 주어진 출력 객체 io(파일, 터미널, 버퍼 등을 나타내며, 네트워킹과 스트림(Networking and Streams) 참고)에 객체를 출력하는 다음 메서드를 정의하면 돼요.
julia> Base.show(io::IO, z::Polar) = print(io, z.r, " * exp(", z.Θ, "im)")
Polar 객체 표시에 대한 더 세밀한 제어도 가능해요. 특히 어떤 때는 REPL과 다른 대화형 환경에서 단일 객체를 표시하는 데 쓰는 상세한 다중 행 출력 형식과, print에 쓰거나 다른 객체(예: 배열)의 일부로 표시할 때 쓰는 더 컴팩트한 단일 행 형식, 둘 다 원할 때가 있어요. 기본적으로 두 경우 모두 show(io, z) 함수가 호출되지만, text/plain MIME 타입을 두 번째 인자로 받는 세 인자 형태의 show를 오버로드해서 객체 표시용 다른 다중 행 형식을 정의할 수 있어요 (자세한 내용은 [멀티미디어 I/O(Multimedia I/O)](@ref Multimedia-I/O) 참고). 예를 들어,
julia> Base.show(io::IO, ::MIME"text/plain", z::Polar{T}) where{T} =
print(io, "Polar{$T} complex number:\n ", z)
(여기서 print(..., z)는 두 인자 show(io, z) 메서드를 호출할 거예요.) 그러면 결과는 다음과 같아요.
julia> Polar(3, 4.0)
Polar{Float64} complex number:
3.0 * exp(4.0im)
julia> [Polar(3, 4.0), Polar(4.0,5.3)]
2-element Vector{Polar{Float64}}:
3.0 * exp(4.0im)
4.0 * exp(5.3im)
Polar 값의 배열에는 여전히 단일 행 show(io, z) 형식이 사용돼요. 기술적으로 REPL은 display(z)를 호출해 한 줄을 실행한 결과 z를 표시하며, 이는 show(io, MIME("text/plain"), z)(여기서 io는 stdout을 감싼 IOContext)로 기본 설정되고, 그것은 다시 show(io, z)로 기본 설정돼요. 하지만 새 멀티미디어 표시 핸들러를 정의하는 게 아니라면 새 display 메서드를 정의하면 안 돼요 (자세한 내용은 [멀티미디어 I/O(Multimedia I/O)](@ref Multimedia-I/O) 참고).
게다가 다른 MIME 타입에 대한 show 메서드를 정의해 객체의 더 풍부한 표시(HTML, 이미지 등)를 지원하는 환경(예: IJulia)에서 가능하게 할 수도 있어요. 예를 들어 Polar 객체의 위첨자와 이탤릭을 갖춘 형식화된 HTML 표시를 다음과 같이 정의할 수 있어요.
julia> Base.show(io::IO, ::MIME"text/html", z::Polar{T}) where {T} =
println(io, "<code>Polar{$T}</code> complex number: ",
z.r, " <i>e</i><sup>", z.Θ, " <i>i</i></sup>")
그러면 Polar 객체는 HTML 표시를 지원하는 환경에서 자동으로 HTML을 사용해 표시되지만, 원하면 show를 수동으로 호출해 HTML 출력을 얻을 수도 있어요.
julia> show(stdout, "text/html", Polar(3.0,4.0))
<code>Polar{Float64}</code> complex number: 3.0 <i>e</i><sup>4.0 <i>i</i></sup>
<p>An HTML renderer would display this as: <code>Polar{Float64}</code> complex number: 3.0 <i>e</i><sup>4.0 <i>i</i></sup></p>
경험칙으로, 단일 행 show 메서드는 표시된 객체를 만드는 유효한 Julia 표현식을 출력해야 해요. 이 show 메서드가 위 Polar의 단일 행 show 메서드에서 곱셈 연산자(*) 같은 중위 연산자를 포함하면, 다른 객체의 일부로 출력될 때 올바르게 파싱되지 않을 수 있어요. 이를 보려면, 우리 Polar 타입의 특정 인스턴스의 제곱을 취하는 (프로그램 표현 참고) 표현식 객체를 고려해 보세요.
julia> a = Polar(3, 4.0)
Polar{Float64} complex number:
3.0 * exp(4.0im)
julia> print(:($a^2))
3.0 * exp(4.0im) ^ 2
연산자 ^는 *보다 우선순위가 높으므로 (연산자 우선순위와 결합성(Operator Precedence and Associativity) 참고), 이 출력은 (3.0 * exp(4.0im)) ^ 2와 같아야 하는 a ^ 2 표현식을 충실히 나타내지 못해요. 이 문제를 해결하려면 Base.show_unquoted(io::IO, z::Polar, indent::Int, precedence::Int)에 대한 사용자 정의 메서드를 만들어야 해요. 이 메서드는 출력할 때 표현식 객체가 내부적으로 호출해요.
julia> function Base.show_unquoted(io::IO, z::Polar, ::Int, precedence::Int)
if Base.operator_precedence(:*) <= precedence
print(io, "(")
show(io, z)
print(io, ")")
else
show(io, z)
end
end
julia> :($a^2)
:((3.0 * exp(4.0im)) ^ 2)
위에서 정의한 메서드는 호출 연산자의 우선순위가 곱셈의 우선순위보다 높거나 같을 때 show 호출 주위에 괄호를 추가해요. 이 검사 덕분에 괄호 없이 올바르게 파싱되는 표현식(예: :($a + 2)과 :($a == 2))은 출력할 때 괄호를 생략할 수 있어요.
julia> :($a + 2)
:(3.0 * exp(4.0im) + 2)
julia> :($a == 2)
:(3.0 * exp(4.0im) == 2)
어떤 경우에는 문맥에 따라 show 메서드의 동작을 조정하는 것이 유용해요. 이것은 IOContext 타입으로 이룰 수 있는데, 감싸진 IO 스트림과 함께 문맥 속성을 전달할 수 있게 해 줘요. 예를 들어 :compact 속성이 true로 설정되면 show 메서드에서 더 짧은 표현을 만들고, 속성이 false거나 없으면 긴 표현으로 폴백하게 할 수 있어요.
julia> function Base.show(io::IO, z::Polar)
if get(io, :compact, false)::Bool
print(io, z.r, "ℯ", z.Θ, "im")
else
print(io, z.r, " * exp(", z.Θ, "im)")
end
end
이 새 컴팩트 표현은 전달된 IO 스트림이 :compact 속성이 설정된 IOContext 객체일 때 사용돼요. 특히 여러 열이 있는 배열(가로 공간이 제한된)을 출력할 때 그렇죠.
julia> show(IOContext(stdout, :compact=>true), Polar(3, 4.0))
3.0ℯ4.0im
julia> [Polar(3, 4.0) Polar(4.0,5.3)]
1×2 Matrix{Polar{Float64}}:
3.0ℯ4.0im 4.0ℯ5.3im
출력 조정에 쓸 수 있는 공통 속성 목록은 IOContext 문서를 참고하세요.
출력 함수 요약 (Output-function summary)
Julia의 서로 다른 출력 함수들과 그것들이 어떻게 관련되는지 간단히 요약할게요. 대부분의 새 타입은 필요하다면 show 메서드만 정의하면 돼요.
display(x)는 현재 환경에x를 그것이 생각하기에 가장 좋은 방식으로 표시하라고 말해요. (이것은 Jupyter나 Pluto 노트북 같은 곳에서는 그래픽 표시일 수도 있어요.) 기본적으로(예: 스크립트나 텍스트 REPL에서) 적절한io스트림에 대해show(io, "text/plain", x), 동등하게show(io, MIME"text/plain"(), x)를 호출해요. (REPL에서io는stdout을 감싼IOContext예요.) REPL은 평가된 표현식의 결과를 출력하는 데display를 사용해요.- 세 인자
show(io, ::MIME"text/plain", x)메서드는x의 상세한 pretty-printing을 수행해요. 기본적으로(typeof(x)에 대한 세 인자 메서드가 정의되지 않았다면) 두 인자show(io, x)를 호출해요. 두 인자repr("text/plain", x)에 의해 호출돼요. 위에서 논의한 대로 일부 대화형 환경에서x의 더 풍부한 표시를 가능하게 하기 위해 추가 MIME 타입에 대한 다른 세 인자show메서드를 정의할 수 있어요. - 두 인자
show(io, x)는x의 기본 단순 텍스트 표현이에요. 한 인자repr(x)에 의해 호출되며, 보통x를 Julia에 입력할 때 쓰는 형식이에요. 한 인자show(x)는show(stdout, x)를 호출해요. print(io, x)는 기본적으로show(io, x)를 호출하지만, 몇몇 타입은 구별되는print형식을 가져요. 특히x가 문자열일 때print는 원시 텍스트를 출력하는 반면show는 따옴표로 둘러싸인 이스케이프된 문자열을 출력해요. 한 인자print(x)는print(stdout, x)를 호출해요.print는 또한string(x)에 의해 호출돼요. 새 줄을 추가하는println과 색 등을 추가하는printstyled도 참고하세요. 둘 다print를 호출해요.write(io, x)는 정의되어 있다면(새 타입에는 일반적으로 기본 정의가 없어요)x의 "원시" 이진 표현을io에 써요. 예를 들어x::Int32는 4바이트로 쓰여져요.
IOContext 래퍼가 io 스트림에 첨부할 수 있는 메타데이터에 익숙해지는 것도 도움이 돼요. 예를 들어 REPL은 평가된 표현식에 대해 display에서 :limit => true 플래그를 설정해, 출력이 터미널에 맞도록 제한해요. 이 플래그는 get(io, :limit, false)로 조회할 수 있어요. 그리고 예를 들어 여러 열 행렬 안에 포함된 객체를 표시할 때는 :compact => true 플래그가 설정될 수 있는데, 이는 get(io, :compact, false)로 조회할 수 있어요.
"값 타입" ("Value types")
Julia에서는 true나 false 같은 값 에 대해 디스패치할 수 없어요. 하지만 매개변수화된 타입에 대해서는 디스패치할 수 있고, Julia는 "plain bits" 값(타입, 심볼, 정수, 부동소수점 수, 튜플 등)을 타입 매개변수로 포함할 수 있게 해 줘요. 흔한 예는 Array{T,N}의 차원 매개변수예요. 여기서 T는 타입(예: Float64)이지만 N은 그냥 Int예요.
값을 매개변수로 받는 자신만의 사용자 정의 타입을 만들고, 그것을 사용해 사용자 정의 타입의 디스패치를 제어할 수 있어요. 이 아이디어를 설명하기 위해 매개변수화된 타입 Val{x}와 그 생성자 Val(x) = Val{x}()를 소개할게요. Val{x}는 더 정교한 계층이 필요하지 않은 경우 이 기법을 활용하는 관례적인 방식 역할을 해요.
Val은 다음과 같이 정의돼요.
julia> struct Val{x}
end
julia> Val(x) = Val{x}()
Val
Val의 구현은 이것이 전부예요. Julia 표준 라이브러리의 일부 함수는 Val 인스턴스를 인자로 받으며, 자신만의 함수를 쓰는 데도 사용할 수 있어요. 예를 들어,
julia> firstlast(::Val{true}) = "First"
firstlast (generic function with 1 method)
julia> firstlast(::Val{false}) = "Last"
firstlast (generic function with 2 methods)
julia> firstlast(Val(true))
"First"
julia> firstlast(Val(false))
"Last"
Julia 전체에서 일관성을 위해 호출 지점은 항상 Val 인스턴스 를 전달해야 해요. 타입 을 쓰지 말고요. 즉 foo(Val{:bar}) 대신 foo(Val(:bar))을 쓰세요.
매개변수화된 "값" 타입(다음 Val 포함)을 잘못 사용하기가 몹시 쉽다는 점은 언급할 가치가 있어요. 불리한 경우 코드 성능을 훨씬 나빠지게 만들기 쉽거든요. 특히 위에 설명한 대로 실제 코드를 작성하고 싶지는 않을 거예요. Val의 적절한(그리고 부적절한) 사용법에 대한 자세한 내용은 [성능 팁의 더 광범위한 논의](@ref man-performance-value-type)를 읽어 보세요.
[^1]: "작은(small)"은 현재 기본값이 4인 max_union_splitting 구성으로 정의돼요.
[^2]: 싱글턴 타입을 가진 몇몇 유명한 언어로는 Haskell, Scala, Ruby가 있어요.
더 알아보기 (Learn more)
- 메서드 (Methods) — 다중 디스패치와 메서드 선택
- 생성자 (Constructors) — 복합 타입 인스턴스 만들기
- 변환과 승격 (Conversion and Promotion) — 타입 간 값 변환