인터페이스

인터페이스

Julia의 강력함과 확장성은 상당 부분 비공식적인 인터페이스(informal interfaces) 모음에서 나와요. 커스텀 타입을 위해 특정 메서드 몇 개만 확장하면, 그 타입의 객체는 해당 기능을 얻을 뿐 아니라, 그 동작을 바탕으로 제네릭하게 작성된 다른 메서드들에서도 쓰일 수 있게 돼요. 이 글에서는 반복, 인덱싱, 추상 배열, 브로드캐스팅, 인스턴스 프로퍼티, 반올림이라는 주요 인터페이스가 각각 어떤 메서드를 구현해야 하는지 살펴볼게요.

출처: Interfaces — The Julia Language

본문

Julia의 강력함과 확장성 중 많은 부분이 비공식적인 인터페이스 모음에서 나와요. 커스텀 타입을 위해 특정 메서드 몇 개를 확장하면, 그 타입의 객체는 그 기능들만 받는 게 아니라, 그 동작 위에 제네릭하게 구축된 다른 메서드에서도 사용될 수 있게 돼요.

반복 (Iteration)

항상 필요한 메서드가 두 개 있어요.

필요한 메서드 간단 설명
iterate(iter) 첫 번째 항목과 초기 상태의 튜플, 또는 비어 있으면 nothing을 반환
iterate(iter, state) 다음 항목과 다음 상태의 튜플, 또는 남은 항목이 없으면 nothing을 반환

상황에 따라 더 정의해야 하는 메서드도 몇 가지 있어요. Base.IteratorSize(IterType)의 기본 정의가 Base.HasLength()이므로, Base.IteratorSize(IterType)length(iter) 중 적어도 하나는 항상 정의해야 한다는 점을 기억하세요.

메서드 언제 정의해야 하나? 기본 정의 간단 설명
Base.IteratorSize(IterType) 기본값이 적절하지 않을 때 Base.HasLength() 상황에 맞게 Base.HasLength(), Base.HasShape{N}(), Base.IsInfinite(), Base.SizeUnknown() 중 하나
length(iter) Base.IteratorSize()Base.HasLength() 또는 Base.HasShape{N}()을 반환할 때 (정의 없음) 알려진 경우 항목의 개수
size(iter, [dim]) Base.IteratorSize()Base.HasShape{N}()을 반환할 때 (정의 없음) 알려진 경우 각 차원의 항목 개수
Base.IteratorEltype(IterType) 기본값이 적절하지 않을 때 Base.HasEltype() 상황에 맞게 Base.EltypeUnknown() 또는 Base.HasEltype()
eltype(IterType) 기본값이 적절하지 않을 때 Any iterate()가 반환하는 튜플 첫 항목의 타입
Base.isdone(iter, [state]) 반드시 정의해야 함 (이터레이터가 상태를 가지는 경우) missing 반복 완료를 위한 빠른 경로 힌트. 상태ful 이터레이터에 정의하지 않으면, isempty()zip()처럼 완료 여부를 확인하는 함수가 이터레이터를 변경해서 오작동을 일으킬 수 있어요!

순차 반복은 iterate 함수로 구현돼요. 반복하면서 객체를 변경하는 대신, Julia 이터레이터는 반복 상태를 객체 외부에서 추적할 수 있어요. iterate의 반환 값은 항상 값과 상태의 튜플이거나, 남은 요소가 없으면 nothing이에요. 상태 객체는 다음 반복에서 iterate 함수로 다시 전달되며, iterable 객체에 사적인 구현 세부사항으로 간주돼요.

이 함수를 정의하는 모든 객체는 반복 가능(iterable)하고, 반복에 의존하는 많은 함수에서 쓸 수 있어요. 다음 문법이:

for item in iter # 또는 "for item = iter"; # body end

다음으로 번역되므로, for 루프에서 직접 쓸 수도 있어요.

next = iterate(iter)
while next !== nothing
    (item, state) = next
    # body
    next = iterate(iter, state)
end

길이가 정의된, 제곱수 시퀀스 iterable의 간단한 예를 볼게요.

julia> struct Squares
           count::Int
       end

julia> Base.iterate(S::Squares, state=1) = state > S.count ? nothing : (state*state, state+1)

iterate 정의만으로 Squares 타입은 꽤 강력해져요. 모든 요소를 반복할 수 있어요.

julia> for item in Squares(7)
           println(item)
       end
1
4
9
16
25
36
49

in이나 sum처럼 iterable과 함께 동작하는 많은 내장 메서드를 쓸 수 있어요.

julia> 25 in Squares(10)
true

julia> sum(Squares(100))
338350

이 iterable 컬렉션에 대해 Julia에 더 많은 정보를 주기 위해 확장할 수 있는 메서드도 몇 가지 더 있어요. Squares 시퀀스의 요소는 항상 Int가 될 거라는 걸 알고 있어요. eltype 메서드를 확장하면 그 정보를 Julia에 전달하고, 더 복잡한 메서드에서 더 특화된 코드를 만들 수 있게 도와줘요. 시퀀스의 요소 개수도 알고 있으니 length도 확장할 수 있어요.

julia> Base.eltype(::Type{Squares}) = Int # 타입에 대해 정의된다는 점에 주의

julia> Base.length(S::Squares) = S.count

이제 Julia에 모든 요소를 배열로 collect하라고 하면, 각 요소를 Vector{Any}에 생각 없이 push!하는 대신, 알맞은 크기의 Vector{Int}를 미리 할당할 수 있어요.

julia> collect(Squares(4))
4-element Vector{Int64}:
 1
 4
 9
 16

제네릭 구현에 의존할 수 있으면서도, 더 간단한 알고리즘이 있을 때는 특정 메서드를 확장할 수도 있어요. 예를 들어 제곱의 합을 구하는 공식이 있으니, 제네릭 반복 버전을 더 성능 좋은 해법으로 덮어쓸 수 있어요.

julia> Base.sum(S::Squares) = (n = S.count; return n*(n+1)*(2n+1)÷6)

julia> sum(Squares(1803))
1955361914

이것은 Julia Base 전체에서 아주 흔한 패턴이에요. 소수의 필수 메서드가 비공식 인터페이스를 정의하고, 그 위에 더 화려한 동작들이 많이 쌓이는 거죠. 어떤 경우에는 타입이 자기 특정 경우에 더 효율적인 알고리즘을 쓸 수 있다는 걸 알 때, 그 추가 동작들을 더 특화하고 싶을 거예요.

또한 Iterators.reverse(iterator)로 반복하면서 컬렉션을 역순으로 반복할 수 있게 하는 것도 자주 유용해요. 실제로 역순 반복을 지원하려면, 이터레이터 타입 TIterators.Reverse{T}에 대해 iterate를 구현해야 해요. (r::Iterators.Reverse{T}가 있을 때, 타입 T의 밑에 있는 이터레이터는 r.itr이에요.) Squares 예시에서는 Iterators.Reverse{Squares} 메서드를 이렇게 구현하면 돼요.

julia> Base.iterate(rS::Iterators.Reverse{Squares}, state=rS.itr.count) = state < 1 ? nothing : (state*state, state-1)

julia> collect(Iterators.reverse(Squares(4)))
4-element Vector{Int64}:
 16
  9
  4
  1

인덱싱 (Indexing)

구현할 메서드 간단 설명
getindex(X, i) X[i], 인덱스 접근. 스칼라가 아닌 i는 복사본을 할당해야 해요
setindex!(X, v, i) X[i] = v, 인덱스 할당
firstindex(X) 첫 인덱스. X[begin]에 사용
lastindex(X) 마지막 인덱스. X[end]에 사용

위의 Squares iterable에서, 시퀀스의 i번째 요소는 그 값을 제곱하면 쉽게 구할 수 있어요. 이걸 인덱싱 표현식 S[i]로 노출할 수 있죠. 이 동작을 선택하려면, Squaresgetindex를 정의하기만 하면 돼요.

julia> function Base.getindex(S::Squares, i::Int)
           1 <= i <= S.count || throw(BoundsError(S, i))
           return i*i
       end

julia> Squares(100)[23]
529

추가로 S[begin]S[end] 문법을 지원하려면, 유효한 첫·마지막 인덱스를 각각 지정하는 firstindexlastindex를 정의해야 해요.

julia> Base.firstindex(S::Squares) = 1

julia> Base.lastindex(S::Squares) = length(S)

julia> Squares(23)[end]
529

a[3, begin, 7] 같은 다차원 begin/end 인덱싱의 경우에는 firstindex(a, dim)lastindex(a, dim)을 정의해야 해요 (기본적으로 각각 axes(a, dim)firstlast를 호출해요).

다만 위 정의는 정수 인덱스 하나를 받는 getindex만 정의한다는 점에 주의하세요. Int가 아닌 다른 것으로 인덱싱하면 매칭되는 메서드가 없다는 MethodError가 던져져요. 범위나 Int 벡터로 인덱싱을 지원하려면 별도의 메서드를 작성해야 해요.

julia> Base.getindex(S::Squares, i::Number) = S[convert(Int, i)]

julia> Base.getindex(S::Squares, I) = [S[i] for i in I]

julia> Squares(10)[[3, 4., 5]]
3-element Vector{Int64}:
  9
 16
 25

이제 내장 타입이 지원하는 인덱싱 연산 중 일부를 지원하기 시작했지만, 아직 빠진 동작이 꽤 많아요. 이 Squares 시퀀스는 동작을 추가할수록 점점 벡터처럼 보이네요. 이런 동작들을 직접 전부 정의하는 대신, AbstractArray의 하위 타입으로 공식 정의할 수 있어요.

추상 배열 (Abstract Arrays)

구현할 메서드 간단 설명
size(A) A의 차원을 담은 튜플 반환
getindex(A, i::Int) (IndexLinear일 때) 선형 스칼라 인덱싱
getindex(A, I::Vararg{Int, N}) (IndexCartesian일 때, N = ndims(A)) N차원 스칼라 인덱싱
선택적 메서드 기본 정의 간단 설명
IndexStyle(::Type) IndexCartesian() IndexLinear() 또는 IndexCartesian() 반환. 아래 설명 참고
setindex!(A, v, i::Int) (IndexLinear일 때) 스칼라 인덱스 할당
setindex!(A, v, I::Vararg{Int, N}) (IndexCartesian일 때, N = ndims(A)) N차원 스칼라 인덱스 할당
getindex(A, I...) 스칼라 getindex로 정의 다차원·비스칼라 인덱싱
setindex!(A, X, I...) 스칼라 setindex!로 정의 다차원·비스칼라 인덱스 할당
iterate 스칼라 getindex로 정의 반복
length(A) prod(size(A)) 요소 개수
similar(A) similar(A, eltype(A), size(A)) 같은 모양과 요소 타입을 가진 변경 가능 배열 반환
similar(A, ::Type{S}) similar(A, S, size(A)) 같은 모양과 지정 요소 타입을 가진 변경 가능 배열 반환
similar(A, dims::Dims) similar(A, eltype(A), dims) 같은 요소 타입과 크기 dims를 가진 변경 가능 배열 반환
similar(A, ::Type{S}, dims::Dims) Array{S}(undef, dims) 지정 요소 타입과 크기를 가진 변경 가능 배열 반환
비전통 인덱스 기본 정의 간단 설명
axes(A) map(OneTo, size(A)) 유효 인덱스인 AbstractUnitRange{<:Integer} 튜플 반환. 축은 자기 자신의 축이어야 함, 즉 axes.(axes(A),1) == axes(A) 만족
similar(A, ::Type{S}, inds) similar(A, S, Base.to_shape(inds)) 지정 인덱스 inds를 가진 변경 가능 배열 반환 (아래 참고)
similar(T::Union{Type,Function}, inds) T(Base.to_shape(inds)) T와 유사하고 지정 인덱스 inds를 가진 배열 반환 (아래 참고)

타입을 AbstractArray의 하위 타입으로 정의하면, 단일 요소 접근 위에 구축된 반복과 다차원 인덱싱을 포함한 매우 큰 동작 집합을 상속받아요. 더 지원되는 메서드는 배열 매뉴얼 페이지Julia Base 섹션에서 확인할 수 있어요.

AbstractArray 하위 타입을 정의할 때 핵심 요소는 IndexStyle이에요. 인덱싱은 배열의 아주 중요한 부분이고 핫 루프에서 자주 일어나기 때문에, 인덱싱과 인덱스 할당을 모두 최대한 효율적으로 만드는 게 중요해요. 배열 데이터 구조는 보통 두 방식 중 하나로 정의돼요. 하나는 인덱스 하나만으로 요소에 가장 효율적으로 접근하는 방식(선형 인덱싱)이고, 다른 하나는 모든 차원에 대해 인덱스가 지정된 방식으로 요소에 접근하는 방식이에요. 이 두 방식을 Julia는 IndexLinear()IndexCartesian()으로 구분해요. 선형 인덱스를 여러 인덱스 첨자로 변환하는 건 대개 매우 비싸기 때문에, 이 트레이트 기반 메커니즘은 모든 배열 타입에 대해 효율적인 제네릭 코드를 가능하게 해줘요.

이 구분이 타입이 어떤 스칼라 인덱싱 메서드를 정의해야 하는지를 결정해요. IndexLinear() 배열은 간단해요. getindex(A::ArrayType, i::Int)만 정의하면 돼요. 이후 배열을 다차원 인덱스 집합으로 인덱싱하면, 폴백 getindex(A::AbstractArray, I...)가 그 인덱스를 효율적으로 선형 인덱스 하나로 변환하고 위 메서드를 호출해요. 반면 IndexCartesian() 배열은 지원하는 각 차원에 대해, ndims(A)개의 Int 인덱스를 가진 메서드를 정의해야 해요. 예를 들어 SparseArrays 표준 라이브러리 모듈의 SparseMatrixCSC는 2차원만 지원하므로 getindex(A::SparseMatrixCSC, i::Int, j::Int)만 정의해요. setindex!도 마찬가지예요.

위의 제곱수 시퀀스로 돌아가서, AbstractArray{Int, 1}의 하위 타입으로 정의할 수도 있어요.

julia> struct SquaresVector <: AbstractArray{Int, 1}
           count::Int
       end

julia> Base.size(S::SquaresVector) = (S.count,)

julia> Base.IndexStyle(::Type{<:SquaresVector}) = IndexLinear()

julia> Base.getindex(S::SquaresVector, i::Int) = i*i

AbstractArray의 두 매개변수를 지정하는 게 아주 중요하다는 점에 주의하세요. 첫 번째는 eltype, 두 번째는 ndims를 정의해요. 그 상위 타입과 위 세 메서드만 있으면 SquaresVector는 반복 가능하고 인덱싱 가능하며 완전히 동작하는 배열이 돼요.

julia> s = SquaresVector(4)
4-element SquaresVector:
 1
 4
 9
 16

julia> s[s .> 8]
2-element Vector{Int64}:
  9
 16

julia> s + s
4-element Vector{Int64}:
  2
  8
 18
 32

julia> sin.(s)
4-element Vector{Float64}:
  0.8414709848078965
 -0.7568024953079282
  0.4121184852417566
 -0.2879033166650653

더 복잡한 예시로, Dict 위에 구축한 우리만의 장난감 N차원 sparse 유사 배열 타입을 정의해볼게요.

julia> struct SparseArray{T,N} <: AbstractArray{T,N}
           data::Dict{NTuple{N,Int}, T}
           dims::NTuple{N,Int}
       end

julia> SparseArray(::Type{T}, dims::Int...) where {T} = SparseArray(T, dims);

julia> SparseArray(::Type{T}, dims::NTuple{N,Int}) where {T,N} = SparseArray{T,N}(Dict{NTuple{N,Int},T}(), dims);

julia> Base.size(A::SparseArray) = A.dims

julia> Base.similar(A::SparseArray, ::Type{T}, dims::Dims) where {T} = SparseArray(T, dims)

julia> Base.getindex(A::SparseArray{T,N}, I::Vararg{Int,N}) where {T,N} = get(A.data, I, zero(T))

julia> Base.setindex!(A::SparseArray{T,N}, v, I::Vararg{Int,N}) where {T,N} = (A.data[I] = v)

이것은 IndexCartesian 배열이므로, 배열 차원 수에서 getindexsetindex!을 직접 정의해야 해요. SquaresVector와 달리 setindex!을 정의할 수 있어서, 배열을 변경(mutate)할 수 있어요.

julia> A = SparseArray(Float64, 3, 3)
3×3 SparseArray{Float64, 2}:
 0.0  0.0  0.0
 0.0  0.0  0.0
 0.0  0.0  0.0

julia> fill!(A, 2)
3×3 SparseArray{Float64, 2}:
 2.0  2.0  2.0
 2.0  2.0  2.0
 2.0  2.0  2.0

julia> A[:] = 1:length(A); A
3×3 SparseArray{Float64, 2}:
 1.0  4.0  7.0
 2.0  5.0  8.0
 3.0  6.0  9.0

AbstractArray를 인덱싱한 결과는 그 자체가 배열일 수 있어요 (예를 들어 AbstractRange로 인덱싱할 때). AbstractArray 폴백 메서드는 similar로 적절한 크기와 요소 타입의 Array를 할당하고, 위에서 설명한 기본 인덱싱 메서드로 채워요. 하지만 배열 래퍼를 구현할 때는 결과도 래핑되길 원하는 경우가 많아요.

julia> A[1:2,:]
2×3 SparseArray{Float64, 2}:
 1.0  4.0  7.0
 2.0  5.0  8.0

이 예시에서는 Base.similar(A::SparseArray, ::Type{T}, dims::Dims) where T를 정의해 적절한 래핑 배열을 만들어서 이뤄져요. (similar는 1·2 인자 형식을 지원하지만, 대부분의 경우 3-인자 형식만 특화하면 돼요.) 이것이 동작하려면 SparseArray가 변경 가능(setindex! 지원)해야 해요. SparseArraysimilar·getindex·setindex!을 정의하면 배열을 copy하는 것도 가능해져요.

julia> copy(A)
3×3 SparseArray{Float64, 2}:
 1.0  4.0  7.0
 2.0  5.0  8.0
 3.0  6.0  9.0

위의 모든 반복·인덱싱 메서드에 더해, 이 타입들은 서로 상호작용하고 Julia Base에서 AbstractArrays에 정의된 대부분의 메서드를 사용할 수 있어요.

julia> A[SquaresVector(3)]
3-element SparseArray{Float64, 1}:
 1.0
 4.0
 9.0

julia> sum(A)
45.0

비전통적 인덱싱(1이 아닌 값에서 시작하는 인덱스)을 허용하는 배열 타입을 정의한다면, axes를 특화해야 해요. dims 인자(보통 Dims 크기 튜플)가 AbstractUnitRange 객체, 어쩌면 우리가 직접 설계한 범위 타입 Ind를 받을 수 있도록 similar도 특화해야 해요. 자세한 내용은 커스텀 인덱스 배열을 참고하세요.

스트라이드 배열 (Strided Arrays)

구현할 메서드 간단 설명
strides(A) 각 차원에서 인접 요소 사이의 메모리 거리(요소 수)를 튜플로 반환. AAbstractArray{T,0}이면 빈 튜플 반환
Base.unsafe_convert(::Type{Ptr{T}}, A) 배열의 네이티브 주소 반환
Base.elsize(::Type{<:A}) 배열에서 연속 요소 사이의 스트라이드 반환
선택적 메서드 기본 정의 간단 설명
stride(A, i::Int) strides(A)[i] k차원에서 인접 요소 사이의 메모리 거리(요소 수) 반환

스트라이드 배열은 고정 스트라이드로 메모리에 저장된 항목을 가진 AbstractArray의 하위 타입이에요. 배열의 요소 타입이 BLAS와 호환되면, 스트라이드 배열은 BLAS와 LAPACK 루틴을 활용해 더 효율적인 선형대수 연산을 할 수 있어요. 사용자 정의 스트라이드 배열의 전형적인 예는 추가 구조를 가진 표준 Array를 감싸는 것이에요.

경고: 밑에 있는 저장소가 실제로 스트라이드가 아니라면 이 메서드들을 구현하지 마세요. 잘못된 결과나 세그멘테이션 오류를 일으킬 수 있어요.

어떤 배열 타입이 스트라이드인지 아닌지 보여주는 예시를 몇 개 볼게요.

1:5        # 스트라이드 아님 (이 배열과 관련된 저장소가 없음)
Vector(1:5) # 스트라이드, strides (1,)
A = [1 5; 2 6; 3 7; 4 8]   # 스트라이드, strides (1,4)
V = view(A, 1:2, :)        # 스트라이드, strides (1,4)
V = view(A, 1:2:3, 1:2)    # 스트라이드, strides (2,4)
V = view(A, [1,2,4], :)    # 스트라이드 아님, 행 사이 간격이 고정이 아니기 때문

브로드캐스팅 커스터마이징

구현할 메서드 간단 설명
Base.BroadcastStyle(::Type{SrcType}) = SrcStyle() SrcType의 브로드캐스팅 동작
Base.similar(bc::Broadcasted{DestStyle}, ::Type{ElType}) 출력 컨테이너 할당
선택적 메서드
Base.BroadcastStyle(::Style1, ::Style2) = Style12() 스타일 혼합 시 우선순위 규칙
Base.axes(x) axes(x)처럼 x의 인덱스 선언
Base.broadcastable(x) xaxes가 있고 인덱싱을 지원하는 객체로 변환
기본 메커니즘 우회
Base.copy(bc::Broadcasted{DestStyle}) broadcast의 커스텀 구현
Base.copyto!(dest, bc::Broadcasted{DestStyle}) DestStyle에 특화된 broadcast!의 커스텀 구현
Base.copyto!(dest::DestType, bc::Broadcasted{Nothing}) DestType에 특화된 broadcast!의 커스텀 구현
Base.Broadcast.broadcasted(f, args...) 융합 표현식 내에서 기본 lazy 동작 재정의
Base.Broadcast.instantiate(bc::Broadcasted{DestStyle}) lazy 브로드캐스트의 축 계산 재정의

브로드캐스팅broadcastbroadcast!를 명시적으로 호출하거나, A .+ bf.(x, y) 같은 "점(dot)" 연산으로 암시적으로 일어나요. axes가 있고 인덱싱을 지원하는 모든 객체는 브로드캐스팅에서 인자로 참여할 수 있고, 기본적으로 결과는 Array에 저장돼요. 이 기본 프레임워크는 세 가지 주요 방식으로 확장할 수 있어요.

  • 모든 인자가 브로드캐스트를 지원하도록 보장하기
  • 주어진 인자 집합에 적절한 출력 배열 선택하기
  • 주어진 인자 집합에 효율적인 구현 선택하기

모든 타입이 axes와 인덱싱을 지원하는 건 아니지만, 브로드캐스트에 허용하면 편리한 타입이 많아요. Base.broadcastable 함수는 브로드캐스트의 각 인자에 호출돼서, axes와 인덱싱을 지원하는 다른 것을 반환하게 해줘요. 기본적으로 이것은 모든 AbstractArrayNumber에 대한 항등 함수예요. 이미 axes와 인덱싱을 지원하니까요.

타입이 브로드캐스트의 컨테이너가 아니라 "0차원 스칼라"(단일 객체)처럼 동작하도록 의도한다면, 다음 메서드를 정의하면 돼요.

Base.broadcastable(o::MyType) = Ref(o)

인자를 0차원 Ref 컨테이너에 감싸 반환해요. 예를 들어 타입 자체, 함수, missing, nothing 같은 특수 싱글턴, 날짜에 대해 이런 래퍼 메서드가 정의돼 있어요.

커스텀 배열 유사 타입은 Base.broadcastable을 특화해 모양을 정의할 수 있지만, collect(Base.broadcastable(x)) == collect(x)라는 규칙을 따라야 해요. 주목할 예외는 AbstractString이에요. 문자열은 그 문자들의 iterable 컬렉션이지만, 브로드캐스트 목적으로는 스칼라처럼 동작하도록 특별 취급돼요 (문자열 참고).

다음 두 단계(출력 배열과 구현 선택)는 주어진 인자 집합에 대해 단일 답을 결정하는 데 의존해요. 브로드캐스트는 인자들의 다양한 타입을 모두 받아 하나의 출력 배열과 하나의 구현으로 압축해야 해요. 브로드캐스트는 이 단일 답을 "스타일"이라고 불러요. 모든 브로드캐스트 가능 객체는 각자 선호하는 스타일을 가지고, 승격 유사 시스템으로 이 스타일들을 단일 답, 즉 "목적지 스타일(destination style)"로 결합해요.

브로드캐스트 스타일

Base.BroadcastStyle은 모든 브로드캐스트 스타일이 파생되는 추상 타입이에요. 함수로 쓰이면 단항(단일 인자)과 이항(이진) 두 가지 형태가 가능해요. 단항 변형은 특정 브로드캐스팅 동작 및/또는 출력 타입을 구현하겠다는 뜻이고, 기본 폴백인 Broadcast.DefaultArrayStyle에 의존하지 않겠다는 뜻이에요.

이 기본값을 재정의하려면 객체에 커스텀 BroadcastStyle을 정의할 수 있어요.

struct MyStyle <: Broadcast.BroadcastStyle end
Base.BroadcastStyle(::Type{<:MyType}) = MyStyle()

MyStyle을 정의하지 않아도 편리한 경우가 있어요. 그럴 때는 일반적인 브로드캐스트 래퍼 중 하나를 활용할 수 있어요.

  • Base.BroadcastStyle(::Type{<:MyType}) = Broadcast.Style{MyType}()는 임의의 타입에 쓸 수 있어요.
  • MyTypeAbstractArray라면 Base.BroadcastStyle(::Type{<:MyType}) = Broadcast.ArrayStyle{MyType}()를 선호해요.
  • 특정 차원만 지원하는 AbstractArrays에는 Broadcast.AbstractArrayStyle{N}의 하위 타입을 만들어요 (아래 참고).

브로드캐스트 연산에 여러 인자가 관여하면, 개별 인자 스타일이 결합되어 출력 컨테이너 타입을 제어하는 단일 DestStyle을 결정해요. 자세한 내용은 아래를 참고하세요.

적절한 출력 배열 선택

브로드캐스트 스타일은 디스패치와 특화를 위해 모든 브로드캐스트 연산마다 계산돼요. 결과 배열의 실제 할당은 similar가 담당하는데, 첫 인자로 Broadcasted 객체를 사용해요.

Base.similar(bc::Broadcasted{DestStyle}, ::Type{ElType})

폴백 정의는 다음과 같아요.

similar(bc::Broadcasted{DefaultArrayStyle{N}}, ::Type{ElType}) where {N,ElType} = similar(Array{ElType}, axes(bc))

필요하다면 이 인자들 중 일부 또는 전부를 특화할 수 있어요. 마지막 인자 bc는 (잠재적으로 융합된) 브로드캐스트 연산의 lazy 표현인 Broadcasted 객체예요. 래퍼의 가장 중요한 필드는 함수와 인자 목록을 각각 나타내는 fargs예요. 인자 목록에는 다른 중첩 Broadcasted 래퍼가 포함될 수 있고 실제로 자주 포함돼요.

완전한 예시로, 배열과 단일 문자를 저장하는 ArrayAndChar 타입을 만들었다고 해볼게요.

struct ArrayAndChar{T,N} <: AbstractArray{T,N}
    data::Array{T,N}
    char::Char
end

Base.size(A::ArrayAndChar) = size(A.data)
Base.getindex(A::ArrayAndChar{T,N}, inds::Vararg{Int,N}) where {T,N} = A.data[inds...]
Base.setindex!(A::ArrayAndChar{T,N}, val, inds::Vararg{Int,N}) where {T,N} = A.data[inds...] = val
Base.showarg(io::IO, A::ArrayAndChar, toplevel) = print(io, typeof(A), " with char '", A.char, "'")

브로드캐스팅이 char "메타데이터"를 보존하길 원할 수 있겠죠. 먼저 정의해요.

Base.BroadcastStyle(::Type{<:ArrayAndChar}) = Broadcast.ArrayStyle{ArrayAndChar}()

이것은 대응하는 similar 메서드도 정의해야 한다는 뜻이에요.

function Base.similar(bc::Broadcast.Broadcasted{Broadcast.ArrayStyle{ArrayAndChar}}, ::Type{ElType}) where ElType
    # 인자에서 ArrayAndChar를 찾는다:
    A = find_aac(bc)
    # A의 char 필드를 사용해 출력 ArrayAndChar를 만든다:
    ArrayAndChar(similar(Array{ElType}, axes(bc)), A.char)
end

"A = find_aac(As)는 인자 중 첫 번째 ArrayAndChar를 반환한다."
find_aac(bc::Base.Broadcast.Broadcasted) = find_aac(bc.args)
find_aac(args::Tuple) = find_aac(find_aac(args[1]), Base.tail(args))
find_aac(x) = x
find_aac(::Tuple{}) = nothing
find_aac(a::ArrayAndChar, rest) = a
find_aac(::Any, rest) = find_aac(rest)

이 정의들로 다음과 같은 동작을 얻을 수 있어요.

julia> a = ArrayAndChar([1 2; 3 4], 'x')
2×2 ArrayAndChar{Int64, 2} with char 'x':
 1  2
 3  4

julia> a .+ 1
2×2 ArrayAndChar{Int64, 2} with char 'x':
 2  3
 4  5

julia> a .+ [5, 10]
2×2 ArrayAndChar{Int64, 2} with char 'x':
  6   7
 13  14

커스텀 구현으로 브로드캐스트 확장

일반적으로 브로드캐스트 연산은, 적용할 함수를 인자들과 함께 들고 있는 lazy Broadcasted 컨테이너로 표현돼요. 이 인자들은 더 중첩된 Broadcasted 컨테이너일 수 있어서, 평가할 큰 표현식 트리를 형성해요. 중첩된 Broadcasted 컨테이너 트리는 점 문법으로 직접 만들어져요. 5 .+ 2.*x는 일시적으로 Broadcasted(+, 5, Broadcasted(*, 2, x))로 표현돼요. 이것은 사용자에게 보이지 않는데, copy 호출로 즉시 실현되기 때문이에요. 하지만 바로 이 컨테이너가 커스텀 타입 작성자에게 브로드캐스트의 확장성 기반을 제공해요. 내장 브로드캐스트 메커니즘은 인자들을 기반으로 결과 타입과 크기를 결정하고, 할당한 다음, 기본 copyto!(::AbstractArray, ::Broadcasted) 메서드로 Broadcasted 객체의 실현을 복사해 넣어요. 내장 폴백 broadcastbroadcast! 메서드도 연산의 일시적 Broadcasted 표현을 만들어 같은 코드 경로를 따르도록 해요. 이 덕분에 커스텀 배열 구현이 자신만의 copyto! 특화를 제공해 브로드캐스팅을 커스터마이즈하고 최적화할 수 있어요. 이것도 계산된 브로드캐스트 스타일에 의해 결정돼요. 이는 연산에서 아주 중요한 부분이라 Broadcasted 타입의 첫 타입 매개변수로 저장돼 디스패치와 특화를 가능하게 해요.

일부 타입에서는 중첩 브로드캐스트 레벨을 가로질러 연산을 "융합"하는 메커니즘을 쓸 수 없거나, 증가적으로 하는 게 더 효율적일 수 있어요. 그런 경우 x .* (x .+ 1)을 마치 broadcast(*, x, broadcast(+, x, 1))처럼, 안쪽 연산이 바깥 연산보다 먼저 평가되도록 할 필요가 있을 수 있어요. 이런 종류의 eager 연산은 약간의 간접성으로 직접 지원돼요. Broadcasted 객체를 직접 만드는 대신, Julia는 융합 표현식 x .* (x .+ 1)Broadcast.broadcasted(*, x, Broadcast.broadcasted(+, x, 1))로 낮춰요. 기본적으로 broadcastedBroadcasted 생성자를 호출해 융합 표현식 트리의 lazy 표현을 만들지만, 특정 함수·인자 조합에 대해 재정의할 수도 있어요.

예를 들어 내장 AbstractRange 객체는 이 메커니즘을 이용해, 모든 단일 요소를 계산하는 대신 시작·간격·길이(또는 끝)만으로 eagerly 평가할 수 있는 브로드캐스트 표현식의 조각을 최적화해요. 다른 모든 메커니즘과 마찬가지로 broadcasted는 인자들의 결합된 브로드캐스트 스타일도 계산하고 노출해요. 그래서 broadcasted(f, args...)를 특화하는 대신, 스타일·함수·인자의 어떤 조합이든 broadcasted(::DestStyle, f, args...)를 특화할 수 있어요.

예를 들어 다음 정의는 범위의 부정을 지원해요.

broadcasted(::DefaultArrayStyle{1}, ::typeof(-), r::OrdinalRange) = range(-first(r), step=-step(r), length=length(r))

제자리(in-place) 브로드캐스트 확장

제자리 브로드캐스팅은 적절한 copyto!(dest, bc::Broadcasted) 메서드를 정의해 지원할 수 있어요. destbc의 특정 하위 타입 중 어느 쪽을 특화하고 싶을 수도 있으니, 패키지 간 모호함을 피하려면 다음 규칙을 권장해요.

특정 스타일 DestStyle을 특화하고 싶다면 다음 시그니처의 메서드를 정의해요.

copyto!(dest, bc::Broadcasted{DestStyle})

선택적으로, 이 형태에서는 dest의 타입도 특화할 수 있어요.

대신 DestStyle을 특화하지 않고 목적지 타입 DestType을 특화하고 싶다면, 다음 시그니처의 메서드를 정의해야 해요.

copyto!(dest::DestType, bc::Broadcasted{Nothing})

이것은 래퍼를 Broadcasted{Nothing}으로 변환하는 copyto! 폴백 구현을 활용해요. 결과적으로 DestType을 특화하는 것은 DestStyle을 특화하는 메서드보다 우선순위가 낮아요.

마찬가지로 copy(::Broadcasted) 메서드로 제자리 밖(out-of-place) 브로드캐스트를 완전히 재정의할 수 있어요.

Broadcasted 객체 다루기

그런 copycopyto! 메서드를 구현하려면, 당연히 각 요소를 계산하기 위해 Broadcasted 래퍼와 함께 작업해야 해요. 두 가지 주요 방법이 있어요.

  • Broadcast.flatten은 잠재적으로 중첩된 연산을 단일 함수와 단일 인자 목록으로 다시 계산해요. 브로드캐스트 모양 규칙을 직접 구현해야 하지만, 제한된 상황에서 유용할 수 있어요.
  • axes(::Broadcasted)CartesianIndices를 반복하고, 결과 CartesianIndex 객체로 인덱싱해 결과를 계산해요.

이진 브로드캐스트 규칙 작성

우선순위 규칙은 이항 BroadcastStyle 호출로 정의돼요.

Base.BroadcastStyle(::Style1, ::Style2) = Style12()

여기서 Style12Style1Style2의 인자를 포함한 출력에 선택하려는 BroadcastStyle이에요. 예를 들어,

Base.BroadcastStyle(::Broadcast.Style{Tuple}, ::Broadcast.AbstractArrayStyle{0}) = Broadcast.Style{Tuple}()

이것은 Tuple이 0차원 배열을 "이긴다"(출력 컨테이너가 튜플이 됨)는 걸 나타내요. 이 호출의 두 인자 순서를 모두 정의할 필요는 없고(해서도 안 되고), 하나만 정의하면 사용자가 인자를 어떤 순서로 제공하든 충분하다는 점을 기억할 가치가 있어요.

AbstractArray 타입에 대해 BroadcastStyle을 정의하면 폴백 선택인 Broadcast.DefaultArrayStyle을 대체해요. DefaultArrayStyle과 추상 상위 타입 AbstractArrayStyle은 고정 차원 요구사항을 가진 특화 배열 타입을 지원하기 위해 차원을 타입 매개변수로 저장해요.

DefaultArrayStyle은 다음 메서드들 때문에 정의된 다른 AbstractArrayStyle에 "진다"고 해요.

BroadcastStyle(a::AbstractArrayStyle{Any}, ::DefaultArrayStyle) = a
BroadcastStyle(a::AbstractArrayStyle{N}, ::DefaultArrayStyle{N}) where N = a
BroadcastStyle(a::AbstractArrayStyle{M}, ::DefaultArrayStyle{N}) where {M,N} = typeof(a)(Val(max(M, N)))

두 개 이상의 DefaultArrayStyle이 아닌 타입 사이에 우선순위를 세우고 싶지 않다면, 이항 BroadcastStyle 규칙을 쓸 필요가 없어요.

배열 타입에 고정 차원 요구사항이 있다면 AbstractArrayStyle을 하위 타입화해야 해요. 예를 들어 sparse 배열 코드에는 다음 정의들이 있어요.

struct SparseVecStyle <: Broadcast.AbstractArrayStyle{1} end
struct SparseMatStyle <: Broadcast.AbstractArrayStyle{2} end

Base.BroadcastStyle(::Type{<:SparseVector}) = SparseVecStyle()
Base.BroadcastStyle(::Type{<:SparseMatrixCSC}) = SparseMatStyle()

AbstractArrayStyle을 하위 타입화할 때마다, Val(N) 인자를 받는 스타일용 생성자를 만들어 차원 결합 규칙도 정의해야 해요. 예를 들어:

SparseVecStyle(::Val{0}) = SparseVecStyle()
SparseVecStyle(::Val{1}) = SparseVecStyle()
SparseVecStyle(::Val{2}) = SparseMatStyle()
SparseVecStyle(::Val{N}) where N = Broadcast.DefaultArrayStyle{N}()

이 규칙들은 SparseVecStyle과 0·1차원 배열의 결합은 또 다른 SparseVecStyle을 만들고, 2차원 배열과의 결합은 SparseMatStyle을 만들며, 더 높은 차원은 밀집 임의 차원 프레임워크로 폴백함을 나타내요. 이 규칙 덕분에 1·2차원 출력을 낳는 연산에서는 브로드캐스트가 sparse 표현을 유지하고, 다른 차원에서는 Array를 만들어요.

인스턴스 프로퍼티 (Instance Properties)

구현할 메서드 기본 정의 간단 설명
propertynames(x::ObjType, private::Bool=false) fieldnames(typeof(x)) 객체 x의 프로퍼티(x.property) 튜플 반환. private=true면 비공개로 유지하려는 프로퍼티 이름도 반환
getproperty(x::ObjType, s::Symbol) getfield(x, s) x의 프로퍼티 s 반환. x.sgetproperty(x, :s) 호출
setproperty!(x::ObjType, s::Symbol, v) setfield!(x, s, v) x의 프로퍼티 sv로 설정. x.s = vsetproperty!(x, :s, v) 호출. v 반환

때로는 최종 사용자가 객체의 필드와 상호작용하는 방식을 바꾸고 싶을 수 있어요. 타입 필드에 직접 접근을 허용하는 대신, object.field을 오버로드함으로써 사용자와 코드 사이에 추상화 계층을 하나 더 제공할 수 있어요. 프로퍼티는 사용자가 객체에서 보는 것이고, 필드는 객체가 실제로 무엇인지를 의미해요.

기본적으로 프로퍼티와 필드는 동일해요. 하지만 이 동작은 바꿀 수 있어요. 예를 들어 극좌표계(polar coordinates)의 평면 위 점 표현을 생각해볼게요.

julia> mutable struct Point
           r::Float64
           ϕ::Float64
       end

julia> p = Point(7.0, pi/4)
Point(7.0, 0.7853981633974483)

위 표에서 설명했듯이 점 접근 p.rgetproperty(p, :r)와 같고, 기본적으로 getfield(p, :r)과 같아요.

julia> propertynames(p)
(:r, :ϕ)

julia> getproperty(p, :r), getproperty(p, :ϕ)
(7.0, 0.7853981633974483)

julia> p.r, p.ϕ
(7.0, 0.7853981633974483)

julia> getfield(p, :r), getproperty(p, :ϕ)
(7.0, 0.7853981633974483)

하지만 사용자가 Point가 좌표를 rϕ(필드)로 저장한다는 사실을 모르고, xy(프로퍼티)로 상호작용하길 원할 수 있어요. 첫 열의 메서드들을 정의해 새 기능을 추가할 수 있어요.

julia> Base.propertynames(::Point, private::Bool=false) = private ? (:x, :y, :r, :ϕ) : (:x, :y)

julia> function Base.getproperty(p::Point, s::Symbol)
           if s === :x
               return getfield(p, :r) * cos(getfield(p, :ϕ))
           elseif s === :y
               return getfield(p, :r) * sin(getfield(p, :ϕ))
           else
               # p.r과 p.ϕ로 필드에 접근할 수 있게 한다
               return getfield(p, s)
           end
       end

julia> function Base.setproperty!(p::Point, s::Symbol, f)
           if s === :x
               y = p.y
               setfield!(p, :r, sqrt(f^2 + y^2))
               setfield!(p, :ϕ, atan(y, f))
               return f
           elseif s === :y
               x = p.x
               setfield!(p, :r, sqrt(x^2 + f^2))
               setfield!(p, :ϕ, atan(f, x))
               return f
           else
               # p.r과 p.ϕ로 필드를 수정할 수 있게 한다
               return setfield!(p, s, f)
           end
       end

getpropertysetproperty! 안에서 점 문법 대신 getfieldsetfield를 써야 한다는 게 중요해요. 점 문법을 쓰면 함수가 재귀적이 되어 타입 추론 문제를 일으킬 수 있거든요. 이제 새 기능을 시험해볼게요.

julia> propertynames(p)
(:x, :y)

julia> p.x
4.949747468305833

julia> p.y = 4.0
4.0

julia> p.r
6.363961030678928

마지막으로, 이렇게 인스턴스 프로퍼티를 추가하는 것은 Julia에서 꽤 드물게 하는 일이고, 그럴 만한 좋은 이유가 있을 때만 해야 한다는 점을 짚어둘게요.

반올림 (Rounding)

구현할 메서드 기본 정의 간단 설명
round(x::ObjType, r::RoundingMode) 없음 x를 반올림하고 결과 반환. 가능하면 x와 같은 타입의 객체를 반환
round(T::Type, x::ObjType, r::RoundingMode) convert(T, round(x, r)) x를 반올림하고 결과를 T로 반환

새 타입에서 반올림을 지원하려면 보통 단일 메서드 round(x::ObjType, r::RoundingMode)를 정의하는 것으로 충분해요. 전달된 반올림 모드가 값을 어느 방향으로 반올림할지 결정해요. 가장 흔히 쓰이는 반올림 모드는 RoundNearest, RoundToZero, RoundDown, RoundUp이에요. 이 모드들이 각각 인자 하나짜리 round·메서드와 trunc·floor·ceil의 정의에 쓰이니까요.

어떤 경우에는 2-인자 메서드 다음에 변환하는 것보다 더 정확하거나 성능이 좋은 3-인자 round 메서드를 정의할 수 있어요. 그렇다면 2-인자 메서드에 더해 3-인자 메서드를 정의해도 무방해요. 반올림 결과를 타입 T의 객체로 표현하는 게 불가능하면, 3-인자 메서드는 InexactError를 던져야 해요.

예를 들어 https://github.com/JuliaPhysics/Measurements.jl 과 비슷하게 가능한 값의 범위를 나타내는 Interval 타입이 있다면, 그 타입의 반올림을 다음과 같이 정의할 수 있어요.

julia> struct Interval{T}
           min::T
           max::T
       end

julia> Base.round(x::Interval, r::RoundingMode) = Interval(round(x.min, r), round(x.max, r))

julia> x = Interval(1.7, 2.2)
Interval{Float64}(1.7, 2.2)

julia> round(x)
Interval{Float64}(2.0, 2.0)

julia> floor(x)
Interval{Float64}(1.0, 2.0)

julia> ceil(x)
Interval{Float64}(2.0, 3.0)

julia> trunc(x)
Interval{Float64}(1.0, 2.0)

더 알아보기