스타일 가이드

스타일 가이드 (Style Guide)

이어지는 절들은 관용적인(idiomatic) 줄리아 코딩 스타일의 몇 가지 측면을 설명해요. 이 규칙들 중 어느 것도 절대적이지 않아요. 언어에 익숙해지도록 돕고, 여러 대안 설계 중에서 선택할 때 참고하라는 차원의 제안일 뿐이에요.

출처: julia 공식 메뉴얼 — Style Guide

본문

들여쓰기 (Indentation)

들여쓰기 레벨당 4칸을 사용하세요.

함수로 작성하되, 스크립트로만 끝내지 마세요 (Write functions, not just scripts)

코드를 최상위 레벨에서 일련의 단계로 작성하는 것은 문제를 풀기 시작하는 빠른 방법이에요. 하지만 가능한 한 빨리 프로그램을 함수들로 나누려고 해야 해요. 함수는 더 재사용 가능하고 테스트하기 쉬우며, 어떤 단계가 수행되고 입력·출력이 무엇인지 명확히 해줘요. 게다가 Julia 컴파일러의 동작 방식 때문에, 함수 안의 코드는 최상위 코드보다 훨씬 빠르게 실행되는 경향이 있어요.

또한 강조할 점은, 함수는 (상수인 pi 같은 걸 제외하면) 전역 변수에 직접 연산하기보다 인자를 받아야 한다는 거예요.

문서 문자열(docstring)을 작성하세요 (Write docstrings)

객체를 설명하는 주석은 보통 편집기와 REPL에서 접근할 수 있도록 docstring"으로 작성해야 해요. 인라인 주석(# comment)과 여러 줄 주석(#= comment =#)은 코드의 독자(사용자가 아닌)에게만 의도된 정보에 적합해요.

지나치게 구체적인 타입을 쓰지 마세요 (Avoid writing overly-specific types)

코드는 가능한 한 일반적이어야 해요. 이렇게 쓰는 대신,

Complex{Float64}(x)

가용한 일반 함수를 쓰는 게 더 좋아요.

complex(float(x))

두 번째 버전은 항상 같은 타입으로 변환하는 게 아니라 x를 적절한 타입으로 변환해요.

이 스타일 지점은 특히 함수 인자에 관련돼요. 예를 들어, 인자가 정말로 어떤 정수든 될 수 있다면(추상 타입인 Integer로 표현되는), 그 인자를 IntInt32 타입으로 선언하지 마세요. 사실 많은 경우, 다른 메서드 정의와 구분할 필요가 있을 때가 아니면 인자 타입을 아예 생략할 수 있어요. 요구되는 연산을 지원하지 않는 타입이 넘어오면 어차피 MethodError가 던져지거든요. (이건 덕 타이핑(duck typing)이라고 알려져 있어요.)

예를 들어, 인자에 1을 더해서 반환하는 addone 함수의 다음 정의들을 봐볼게요.

addone(x::Int) = x + 1                 # works only for Int
addone(x::Integer) = x + oneunit(x)    # any integer type
addone(x::Number) = x + oneunit(x)     # any numeric type
addone(x) = x + oneunit(x)             # any type supporting + and oneunit

addone의 마지막 정의는 oneunit(x와 같은 타입으로 1을 반환하므로 원치 않는 타입 프로모션을 피해줘요)과 + 함수를 그 인자들로 지원하는 모든 타입을 다뤄요. 핵심적으로 알아둬야 할 것은, 일반 정의 addone(x) = x + oneunit(x)만 정의해도 성능 패널티가 전혀 없다는 거예요. Julia가 필요에 따라 자동으로 특수화된 버전을 컴파일하니까요. 예를 들어 addone(12)을 처음 호출하면, Julia는 자동으로 x::Int 인자에 대한 특수화된 addone 함수를 컴파일하고, oneunit 호출은 인라인된 값 1로 대체해요. 따라서 위의 addone 정의 첫 세 개는 네 번째 정의로 완전히 대체돼요.

과도한 인자 다양성은 호출부에서 처리하세요 (Handle excess argument diversity in the caller)

이렇게 쓰는 대신,

function foo(x, y)
    x = Int(x); y = Int(y)
    ...
end
foo(x, y)

이렇게 쓰세요.

function foo(x::Int, y::Int)
    ...
end
foo(Int(x), Int(y))

foo는 실제로 모든 타입의 숫자를 받지 않고, 정말로 Int가 필요하므로 이게 더 좋은 스타일이에요.

여기서 한 가지 쟁점은, 함수가 본질적으로 정수를 요구한다면 비정수를 어떻게 변환할지(예: floor인지 ceiling인지)를 호출자가 결정하도록 강제하는 게 더 좋을 수 있다는 거예요. 다른 쟁점은, 더 구체적인 타입을 선언하면 미래의 메서드 정의를 위한 "공간"이 더 많아진다는 거예요.

인자를 수정하는 함수 이름에는 !를 붙이세요 (Append ! to names of functions that modify their arguments)

이렇게 쓰는 대신,

function double(a::AbstractArray{<:Number})
    for i in eachindex(a)
        a[i] *= 2
    end
    return a
end

이렇게 쓰세요.

function double!(a::AbstractArray{<:Number})
    for i in eachindex(a)
        a[i] *= 2
    end
    return a
end

Julia Base는 이 관례를 곳곳에서 사용하며, 복사 버전과 수정 버전 둘 다 있는 함수(sortsort! 등)와 수정 버전만 있는 함수(push!, pop!, splice! 등)의 예시를 담고 있어요. 이런 함수들이 편의를 위해 수정된 배열을 반환하기도 하는 건 흔한 일이에요.

IO 관련 함수나 난수 생성기(RNG)를 사용하는 함수는 주목할 만한 예외예요. 이런 함수들은 거의 예외 없이 IO나 RNG를 변형해야 하므로, !로 끝나는 함수는 IO 변형이나 RNG 상태 전진 외의 변형을 나타내는 데 쓰여요. 예를 들어 rand(x)는 RNG를 변형하는 반면 rand!(x)는 RNG와 x를 모두 변형해요. 마찬가지로 read(io)io를 변형하고, read!(io, x)는 두 인자를 모두 변형해요.

이상한 타입 Union을 피하세요 (Avoid strange type Unions)

Union{Function,AbstractString} 같은 타입은 보통 어떤 설계가 더 깔끔해질 수 있다는 신호예요.

정교한 컨테이너 타입을 피하세요 (Avoid elaborate container types)

다음 같은 배열을 구성하는 건 별로 도움이 되지 않아요.

a = Vector{Union{Int,AbstractString,Tuple,Array}}(undef, n)

이 경우엔 Vector{Any}(undef, n)이 더 낫죠. 또 여러 대안을 하나의 타입에 우겨넣으려 하기보다, 특정 사용처에 주석을 다는 것(예: a[i]::Int)이 컴파일러에 더 도움이 돼요.

내보낸 메서드(direct field access)보다 선호하세요 (Prefer exported methods over direct field access)

관용적인 Julia 코드는 일반적으로 모듈의 내보낸 메서드를 그 타입에 대한 인터페이스로 다뤄야 해요. 객체의 필드는 대체로 구현 세부사항으로 간주되며, 사용자 코드는 API로 명시되어 있지 않는 한 그 필드에 직접 접근해서는 안 돼요. 여기에는 몇 가지 이점이 있어요.

  • 패키지 개발자는 사용자 코드를 깨뜨리지 않고 구현을 더 자유롭게 바꿀 수 있어요.
  • 메서드를 map 같은 고차 구조에 넘길 수 있어요(예: [z.im for z in zs] 대신 map(imag, zs)).
  • 메서드를 추상 타입에 정의할 수 있어요.
  • 메서드는 서로 다른 타입에 걸쳐 공유할 수 있는 개념적 연산을 표현할 수 있어요(예: real(z)는 Complex 숫자나 Quaternion에서 모두 동작해요).

Julia의 디스패치 시스템은 play(x::MyType)이 그 특정 타입에만 play 메서드를 정의하므로 이 스타일을 장려해요. 다른 타입들은 각자 자체 구현을 갖게 되죠.

비슷하게, 문서에서 달리 명시하지 않는 한 내보내지 않은 함수는 보통 내부용이고 변경될 수 있어요. 이름에 _ 접두사(또는 접미사)를 붙여 "내부"이거나 "구현 세부사항"임을 암시하기도 하지만, 그건 규칙이 아니에요.

이 규칙에 대한 반례(counter-example)로는 NamedTuple, RegexMatch, StatStruct가 있어요.

Julia base/와 일관된 명명 관례를 사용하세요 (Use naming conventions consistent with Julia base/)

  • 모듈과 타입 이름은 대문자와 카멜케이스를 사용해요: module SparseArrays, struct UnitRange.
  • 상수는 전부 대문자와 밑줄을 사용해요 (LOAD_PATH, VERSION).
  • public이나 export로 표시되지 않은 것은 내부용으로 간주되지만, _ 접두사도 객체가 공개용이 아님을 나타내요.
  • 인자 중 하나 이상을 변형하는 함수는 !로 끝나요.
  • 간결함은 가치 있지만, 약어는 피하세요 (indexinindxin이 아니라요). 특정 단어가 약어인지, 어떻게 약어인지 기억하기 어려워지거든요.

함수 이름이 여러 단어를 요구한다면, 그게 둘 이상의 개념을 나타내는 건 아닌지, 더 잘게 쪼개는 게 나은지 고려해보세요.

함수 이름은 스네이크케이스로 써야 해요 (minimum, count_zeros, escape_string). Base는 단어를 붙여 쓰면서(splitpath, readeach) 이 관례를 깨기도 하지만, 이 스타일은 패키지에는 권장되지 않아요.

Julia Base와 비슷한 인자 순서로 함수를 작성하세요 (Write functions with argument ordering similar to Julia Base)

일반 규칙으로, Base 라이브러리는 해당되는 경우 다음 인자 순서를 사용해요.

  1. 함수 인자. 함수 인자를 첫 번째에 두면 여러 줄 익명 함수를 전달하기 위해 do 블록을 쓸 수 있어요.
  2. I/O 스트림. IO 객체를 먼저 지정하면 sprint(show, x)처럼 함수를 sprint에 넘길 수 있어요.
  3. 변형되는 입력. 예를 들어 fill!(x, v)에서 x는 변형되는 객체이고, x에 넣을 값보다 앞에 나타나요.
  4. 타입. 타입을 넘기는 건 보통 출력이 주어진 타입이 된다는 뜻이에요. parse(Int, "1")에서 타입은 파싱할 문자열 앞에 와요. 타입이 먼저 나오는 예는 많지만, read(io, String)에서는 IO 인자가 타입보다 앞에 온다는 점을 알아두면 유용해요. 이는 여기 나열된 순서와 일치해요.
  5. 변형되지 않는 입력. fill!(x, v)에서 v는 변형되지 않고 x 뒤에 나와요.
  6. 키(Key). 연관 컬렉션에서는 키-값 쌍의 키예요. 다른 인덱싱된 컬렉션에서는 인덱스예요.
  7. 값(Value). 연관 컬렉션에서는 키-값 쌍의 값이에요. fill!(x, v) 같은 경우엔 v예요.
  8. 그 외의 모든 것. 다른 모든 인자.
  9. 가변 인자(Varargs). 함수 호출 끝에 무한정 나열할 수 있는 인자를 말해요. 예를 들어 Matrix{T}(undef, dims)에서 차원은 Tuple(예: Matrix{T}(undef, (1,2)))이나 Vararg(예: Matrix{T}(undef, 1, 2))로 줄 수 있어요.
  10. 키워드 인자. Julia에서 키워드 인자는 어차피 함수 정의에서 마지막에 와야 해요. 완전성을 위해 여기에 나열했어요.

대부분의 함수가 위에 나열된 모든 종류의 인자를 받는 건 아니에요. 숫자는 함수에 적용되는 인자에 사용해야 할 우선순위를 나타낼 뿐이에요.

물론 몇 가지 예외가 있어요. 예를 들어 convert에서는 타입이 항상 먼저 와야 해요. setindex!에서는 인덱스를 varargs로 제공할 수 있도록 값이 인덱스보다 먼저 와요.

API를 설계할 때 이 일반적인 순서를 최대한 따르는 것이 함수 사용자에게 더 일관된 경험을 줄 가능성이 높아요.

try-catch를 남용하지 마세요 (Don't overuse try-catch)

오류를 잡는 데 의존하기보다 오류를 피하는 게 더 좋아요.

조건에 괄호를 붙이지 마세요 (Don't parenthesize conditions)

Julia는 ifwhile의 조건에 괄호를 요구하지 않아요. 이렇게 쓰세요.

if a == b

대신에 이렇게요.

if (a == b)

...를 남용하지 마세요 (Don't overuse ...)

함수 인자 스플라이싱은 중독되기 쉬워요. [a..., b...] 대신 그냥 [a; b]를 쓰세요. 이건 이미 배열을 연결해 줘요. collect(a)[a...]보다 낫지만, a가 이미 반복 가능하다면 아예 그대로 두고 배열로 변환하지 않는 게 종종 더 좋아요.

생성자가 자기 타입의 인스턴스를 반환하도록 하세요 (Ensure constructors return an instance of their own type)

타입 T에 메서드 T(x)를 호출하면 일반적으로 타입 T의 값을 반환할 것으로 기대돼요. 예상치 못한 타입을 반환하는 생성자를 정의하면 혼란스럽고 예측 불가능한 동작을 일으킬 수 있어요.

julia> struct Foo{T}
           x::T
       end

julia> Base.Float64(foo::Foo) = Foo(Float64(foo.x))  # Do not define methods like this

julia> Float64(Foo(3))  # Should return `Float64`
Foo{Float64}(3.0)

julia> Foo{Int}(x) = Foo{Float64}(x)  # Do not define methods like this

julia> Foo{Int}(3)  # Should return `Foo{Int}`
Foo{Float64}(3.0)

코드 명확성을 유지하고 타입 일관성을 보장하려면, 항상 생성자가 만들려는 타입의 인스턴스를 반환하도록 설계하세요.

불필요한 정적 파라미터를 사용하지 마세요 (Don't use unnecessary static parameters)

다음 함수 시그니처는

foo(x::T) where {T<:Real} = ...

이렇게 써야 해요.

foo(x::Real) = ...

특히 T가 함수 본문에서 쓰이지 않는다면 말이에요. T를 쓴다 해도, 편하다면 typeof(x)로 대체할 수 있어요. 성능 차이는 없어요. 이건 정적 파라미터에 대한 일반적인 경고가 아니라, 필요하지 않은 사용에 대한 경고라는 점에 유의하세요.

또한 컨테이너 타입은 특히 함수 호출에서 타입 파라미터가 필요할 수 있다는 점도 알아두세요. 더 자세한 내용은 FAQ의 추상 컨테이너를 가진 필드 피하기를 참고하세요.

인스턴스인지 타입인지에 대한 혼란을 피하세요 (Avoid confusion about whether something is an instance or a type)

다음 같은 정의 집합은 혼란스러워요.

foo(::Type{MyType}) = ...
foo(::MyType) = foo(MyType)

문제의 개념을 MyType으로 쓸지 MyType()으로 쓸지 결정하고, 그걸 지켜요.

선호되는 스타일은 기본적으로 인스턴스를 사용하고, 어떤 문제를 풀기 위해 필요해지면 나중에 Type{MyType}와 관련된 메서드만 추가하는 거예요.

타입이 사실상 열거형(enumeration)이라면, 단일(가급적 불변 struct나 primitive) 타입으로 정의하고 열거 값은 그것의 인스턴스로 해야 해요. 생성자와 변환 함수가 값이 유효한지 확인할 수 있어요. 이 설계는 열거형을 추상 타입으로 만드는 것보다 선호되는데, "값"을 하위 타입으로 두는 방식보다 낫기 때문이에요.

매크로를 남용하지 마세요 (Don't overuse macros)

매크로가 실제로는 함수일 수 있는 때를 인지하세요.

매크로 안에서 eval을 호출하는 것은 특히 위험한 경고 신호예요. 그 매크로가 최상위 레벨에서 호출될 때만 동작한다는 뜻이거든요. 그런 매크로를 대신 함수로 작성하면, 필요한 런타임 값을 자연스럽게 접근할 수 있어요.

인터페이스 레벨에서 안전하지 않은 연산을 노출하지 마세요 (Don't expose unsafe operations at the interface level)

네이티브 포인터를 사용하는 타입이 있다면:

mutable struct NativeType
    p::Ptr{UInt8}
    ...
end

다음 같은 정의를 작성하지 마세요.

getindex(x::NativeType, i) = unsafe_load(x.p, i)

문제는 이 타입의 사용자가 연산이 안전하지 않다는 걸 모른 채 x[i]를 쓸 수 있고, 그러면 메모리 버그에 취약해질 수 있다는 거예요.

그런 함수는 연산이 안전한지 확인하거나, 호출자에게 경고하기 위해 이름 어딘가에 unsafe를 넣어야 해요.

기본 컨테이너 타입의 메서드를 오버로드하지 마세요 (Don't overload methods of base container types)

다음 같은 정의를 작성할 수 있어요.

show(io::IO, v::Vector{MyType}) = ...

이렇게 하면 특정 새 요소 타입을 가진 벡터에 대한 사용자 정의 표시를 제공해요. 유혹적이지만 피해야 해요. 문제는 사용자가 Vector() 같은 잘 알려진 타입이 특정 방식으로 동작할 것을 기대한다는 거예요. 그 동작을 지나치게 커스터마이즈하면 다루기 더 어려워질 수 있어요.

타입 해적질(type piracy)을 피하세요 (Avoid type piracy)

"타입 해적질"은 여러분이 정의하지 않은 타입에 대해 Base나 다른 패키지의 메서드를 확장·재정의하는 관행을 말해요. 극단적인 경우 Julia를 크래시시킬 수 있어요(예: 메서드 확장·재정의가 ccall에 잘못된 입력을 전달하게 만드는 경우). 타입 해적질은 코드를 추론하기 어렵게 만들고, 예측·진단하기 어려운 비호환성을 도입할 수 있어요.

예를 들어, 한 모듈에서 심볼에 대한 곱셈을 정의하고 싶다고 가정해볼게요.

module A
import Base.*
*(x::Symbol, y::Symbol) = Symbol(x,y)
end

문제는 이제 Base.*를 사용하는 다른 모듈도 이 정의를 보게 된다는 거예요. Symbol은 Base에서 정의되고 다른 모듈에서 사용되므로, 이 정의가 무관한 코드의 동작을 예기치 않게 바꿀 수 있어요. 여기에는 다른 함수 이름을 쓰거나, 여러분이 정의한 다른 타입으로 Symbol들을 감싸는 것 같은 여러 대안이 있어요.

때로는 연동되는(coupled) 패키지들이 기능을 정의와 분리하기 위해 타입 해적질을 하기도 해요. 특히 협업 저자들이 설계한 패키지이고 정의가 재사용 가능할 때 그렇죠. 예를 들어 한 패키지가 색을 다루는 데 유용한 몇 가지 타입을 제공하고, 다른 패키지가 그 타입들에 대해 색 공간 간 변환을 가능하게 하는 메서드를 정의할 수 있어요. 또 다른 예로, 어떤 C 코드의 얇은 래퍼 역할을 하는 패키지가 있고, 다른 패키지가 그걸 "해적질"해서 더 고수준의 Julia 친화적 API를 구현할 수 있죠.

타입 동등성(type equality)에 주의하세요 (Be careful with type equality)

일반적으로 타입 테스트에는 ==가 아니라 isa<:를 사용해야 해요. 타입의 정확한 동등성을 검사하는 건 보통 알려진 구체 타입과 비교할 때(예: T == Float64)나, 정말 정말 자신이 무슨 짓을 하는지 알 때만 의미가 있어요.

명명된 함수 f에 대해 사소한 익명 함수 x->f(x)를 쓰지 마세요 (Don't write a trivial anonymous function x->f(x) for a named function f)

고차 함수가 자주 익명 함수로 호출되기 때문에, 이게 바람직하거나 심지어 필요하다고 결론 내리기 쉬워요. 하지만 어떤 함수든 "래핑" 없이 직접 전달할 수 있어요. map(x->f(x), a)를 쓰는 대신 map(f, a)를 쓰세요.

가능하면 일반 코드의 숫자 리터럴에 float를 사용하지 마세요 (Avoid using floats for numeric literals in generic code when possible)

숫자를 다루고, 다양한 숫자 타입 인자로 실행될 것으로 예상되는 일반 코드를 작성한다면, 프로모션을 통해 인자에 가능한 한 적게 영향을 주는 숫자 타입의 리터럴을 사용해보세요.

예를 들어,

julia> f(x) = 2.0 * x
f (generic function with 1 method)

julia> f(1//2)
1.0

julia> f(1/2)
1.0

julia> f(1)
2.0

반면에

julia> g(x) = 2 * x
g (generic function with 1 method)

julia> g(1//2)
1//1

julia> g(1/2)
1.0

julia> g(1)
2

보시다시피 Int 리터럴을 사용한 두 번째 버전은 입력 인자의 타입을 보존했지만, 첫 번째는 그렇지 않았어요. 이는 예를 들어 promote_type(Int, Float64) == Float64이고, 곱셈에서 프로모션이 일어나기 때문이에요. 마찬가지로 Rational 리터럴은 Float64 리터럴보다 타입을 덜 방해하지만 Int보다는 더 방해해요.

julia> h(x) = 2//1 * x
h (generic function with 1 method)

julia> h(1//2)
1//1

julia> h(1/2)
1.0

julia> h(1)
2//1

따라서 가능하면 Int 리터럴을 사용하고, 리터럴 비정수 숫자는 Rational{Int}를 사용해서 코드를 더 쉽게 쓰게 만드세요.

더 알아보기