모듈
모듈 (Modules)
코드가 커지면 기능별로 묶어서 관리하고 싶어져요. Julia의 모듈(Module)은 코드를 일관된 단위로 정리하도록 도와주는 장치예요. 문법적으로 module NameOfModule ... end 안에 구획되며, 다음과 같은 특징을 가져요.
본문
- 모듈은 별개의 네임스페이스로, 각각이 새로운 전역 스코프를 도입해요. 이 덕분에 서로 다른 모듈에 있으면 같은 이름을 다른 함수나 전역 변수에 충돌 없이 쓸 수 있어요.
- 모듈에는 상세한 네임스페이스 관리 수단이 있어요. 각 모듈은
export하고public으로 표시하는 이름 집합을 정의하고,using과import로 다른 모듈의 이름을 가져올 수 있죠(아래에서 자세히 설명할게요). - 모듈은 **미리 컴파일(precompile)**될 수 있어 로딩을 빠르게 하고, 런타임 초기화 코드를 담을 수 있어요.
보통 더 큰 Julia 패키지에서는 모듈 코드를 파일로 정리해요. 예를 들면:
module SomeModule
# export, public, using, import 문장은 보통 여기에 옴 (아래에서 설명)
include("file1.jl")
include("file2.jl")
end
파일과 파일 이름은 모듈과 대체로 무관해요. 모듈은 오직 모듈 표현식과만 연결되죠. 모듈당 여러 파일, 파일당 여러 모듈이 가능해요. include는 소스 파일의 내용을 포함하는 모듈의 전역 스코프에서 평가한 것처럼 동작해요. 이 장에서는 짧고 간단한 예시를 쓸 거라 include는 사용하지 않을게요.
권장 스타일로는 모듈 본문을 들여쓰지 않아요. 들여쓰면 파일 전체가 들여지게 되니까요. 또 모듈 이름은 (타입처럼) UpperCamelCase를 쓰는 게 흔하고, 해당 모듈과 비슷한 이름의 식별자가 들어 있다면 특히 이름 충돌을 피하려고 (가능하면) 복수형을 쓰죠. 예를 들어:
module FastThings
struct FastThing
...
end
end
네임스페이스 관리 (Namespace management)
네임스페이스 관리는 어떤 모듈 안의 이름을 다른 모듈에서 사용할 수 있게 해주는 언어 기능을 말해요. 관련 개념과 기능을 아래에서 자세히 다룰게요.
정규화된 이름 (Qualified names)
전역 스코프의 함수·변수·타입 이름(sin, ARGS, UnitRange 같은)은 항상 어떤 모듈에 속하는데, 그 모듈을 *부모 모듈(parent module)*이라 불러요. 대화형 세션에서 parentmodule로 찾을 수 있죠. 예:
julia> parentmodule(UnitRange)
Base
이런 이름들은 부모 모듈 밖에서도 모듈 이름을 접두어로 붙여(Base.UnitRange처럼) 참조할 수 있는데, 이것을 *정규화된 이름(qualified name)*이라 해요. 부모 모듈은 Base.Math.sin 같은 하위 모듈 체인으로도 접근 가능한데, 여기서 Base.Math를 *모듈 경로(module path)*라고 불러요. 문법적 모호성 때문에 연산자처럼 기호만으로 이루어진 이름을 정규화하려면 콜론을 끼워 넣어야 해요(예: Base.:+). 소수의 연산자는 추가로 괄호가 필요하죠(예: Base.:(==)).
이름이 정규화되면 항상 접근 가능하고, 함수의 경우 정규화된 이름을 함수 이름으로 사용해 그 함수에 메서드를 추가할 수도 있어요.
모듈 안에서 변수 이름은 값을 할당하지 않고 global x로 선언해 "예약"할 수 있어요. 이렇게 하면 로드 시간 이후에 초기화되는 전역 변수들의 이름 충돌을 막아줘요. M.x = y 문법으로는 다른 모듈의 전역 변수에 할당할 수 없어요. 전역 할당은 항상 모듈 로컬이기 때문이죠.
내보내기 목록 (Export lists)
export로 이름(함수, 타입, 전역 변수, 상수 등)을 모듈의 *내보내기 목록(export list)*에 추가할 수 있어요. using으로 모듈을 가져올 때 바로 이 심볼들이 가져와지죠. 보통 모듈 정의의 맨 위나 그 근처에 둬서 소스 코드를 읽는 사람이 쉽게 찾게 해요.
julia> module NiceStuff
export nice, DOG
struct Dog end # 싱글턴 타입, 내보내지 않음
const DOG = Dog() # 이름 붙은 인스턴스, 내보냄
nice(x) = "nice $x" # 함수, 내보냄
end;
하지만 이건 스타일 제안일 뿐이에요. 모듈에는 임의의 위치에 여러 개의 export 문장이 있을 수 있어요.
API(애플리케이션 프로그래밍 인터페이스)의 일부를 이루는 이름을 내보내는 게 일반적이에요. 위 코드에서 내보내기 목록은 사용자가 nice와 DOG를 쓰길 바란다는 뜻이죠. 그런데 정규화된 이름이 항상 식별자를 접근 가능하게 만들기 때문에, 이건 그저 API를 정리하는 하나의 선택지일 뿐이에요. 다른 언어와 달리 Julia에는 모듈 내부를 진짜로 숨길 수 있는 기능이 없어요.
또 어떤 모듈은 이름을 전혀 내보내지 않기도 해요. API에 derivative처럼 다른 모듈의 내보내기 목록과 쉽게 충돌할 수 있는 흔한 단어를 쓸 때 그렇게 하죠. 이름 충돌을 관리하는 법은 아래에서 볼게요.
using NiceStuff를 호출하는 사람들의 네임스페이스로 이름을 내보내지 않으면서 공개(public)로 표시하고 싶다면, export 대신 public을 쓰면 돼요. public은 공개 이름(들)을 공개 API의 일부로 표시하지만 네임스페이스에는 아무 영향도 주지 않아요. public 키워드는 Julia 1.11 이상에서만 쓸 수 있어요. Julia 1.10 이하와의 호환성을 유지하려면 Compat 패키지의 @compat 매크로나 버전 인식 방식의 구조물을 쓰세요.
VERSION >= v"1.11.0-DEV.469" && eval(Meta.parse("public a, b, c"))
독립적인 using과 import
대화형 사용에서 모듈을 불러오는 가장 흔한 방법은 using ModuleName이에요. 이건 ModuleName에 연결된 코드를 불러오고, 주변 전역 네임스페이스에
- 모듈 이름과
- 내보내기 목록의 원소들
을 가져와요. 기술적으로 using ModuleName 문장은 ModuleName이라는 모듈이 필요할 때 이름을 해석하기 위해 사용 가능해진다는 뜻이에요. 현재 모듈에 정의가 없는 전역 변수를 만나면, 시스템은 ModuleName이 내보낸 변수들 사이에서 그것을 찾고 발견되면 사용해요. 즉 현재 모듈 안에서 그 전역변수의 모든 사용이 ModuleName 안의 그 변수 정의로 해석된다는 뜻이죠.
패키지에서 모듈을 불러오려면 using ModuleName을 쓰면 돼요. 로컬에서 정의한 모듈에서 불러오려면 모듈 이름 앞에 점을 붙여 using .ModuleName처럼 해야 해요.
아까의 예시를 이어서,
julia> using .NiceStuff
위 코드를 불러와 NiceStuff(모듈 이름), DOG, nice를 사용 가능하게 만들어요. Dog는 내보내기 목록에 없지만, 모듈 경로(여기서는 그냥 모듈 이름)로 정규화해 NiceStuff.Dog라고 접근할 수 있어요.
중요한 점은, using ModuleName이야말로 내보내기 목록이 의미를 지니는 유일한 형태라는 거예요.
반면,
julia> import .NiceStuff
는 모듈 이름만 스코프로 가져와요. 내용물에 접근하려면 NiceStuff.DOG, NiceStuff.Dog, NiceStuff.nice처럼 써야 하죠. 보통 import ModuleName은 네임스페이스를 깔끔하게 유지하고 싶은 맥락에서 써요. 다음 절에서 보겠지만 import .NiceStuff는 using .NiceStuff: NiceStuff와 동등해요.
같은 종류의 using·import 문장 여럿을 콤마로 구분해 조합할 수도 있어요. 예:
julia> using LinearAlgebra, Random
특정 식별자를 쓰는 using과 import, 메서드 추가
using ModuleName:이나 import ModuleName: 뒤에 콤마로 구분된 이름 목록이 오면, 모듈은 불러와지지만 그 문장으로 그 특정 이름들만 네임스페이스로 가져와져요. 예를 들어,
julia> using .NiceStuff: nice, DOG
는 nice와 DOG 이름을 가져와요. 중요한 건, 이 경우 모듈 이름 NiceStuff는 네임스페이스에 들어오지 않는다는 점이에요. 모듈 이름도 쓸 수 있게 하려면 명시적으로 나열해야 해요.
julia> using .NiceStuff: nice, DOG, NiceStuff
두 개 이상의 패키지/모듈이 어떤 이름을 내보내는데 그 이름이 각 패키지에서 같은 것을 가리키지 않고, 그 패키지들이 명시적인 이름 목록 없이 using으로 불러와졌다면, 그 이름을 정규화 없이 참조하는 것은 오류예요. 그래서 의존성과 Julia의 미래 버전과의 호환을 염두에 둔 코드(예: 릴리스된 패키지의 코드)는 각 패키지에서 쓰는 이름을 using Foo: Foo, f처럼 명시적으로 나열하고, using Foo처럼 쓰지 말 것을 권해요.
겉보기에 똑같아 보이는 두 형태가 있는 이유는 import ModuleName: f만이 f에 모듈 경로 없이 메서드를 추가할 수 있게 하기 때문이에요. 즉 다음 예시는 오류를 냅니다.
julia> using .NiceStuff: nice
julia> struct Cat end
julia> nice(::Cat) = "nice 😸"
ERROR: invalid method definition in Main: function NiceStuff.nice must be explicitly imported to be extended
Stacktrace:
[1] top-level scope @ none:1
이 오류는 단지 사용하려던 다른 모듈의 함수에 실수로 메서드를 추가하는 걸 막아줘요.
이 문제를 다루는 방법은 두 가지예요. 함수 이름을 항상 모듈 경로로 정규화하거나:
julia> using .NiceStuff
julia> struct Cat end
julia> NiceStuff.nice(::Cat) = "nice 😸"
아니면 특정 함수 이름을 import하면 돼요.
julia> import .NiceStuff: nice
julia> struct Mouse end
julia> nice(::Mouse) = "nice 🐭"
nice (generic function with 3 methods)
어느 쪽을 고를지는 스타일 문제예요. 첫 번째 형태는 다른 모듈의 함수에 메서드를 추가하고 있음을 분명히 보여주고(import와 메서드 정의가 서로 다른 파일에 있을 수 있다는 걸 기억하세요), 두 번째 형태는 더 짧아서 여러 메서드를 정의할 때 특히 편리해요.
일단 변수가 using이나 import로 보이게 되면, 모듈은 같은 이름으로 자기 변수를 만들 수 없어요. 가져온 변수는 읽기 전용이고, 전역 변수에 할당하면 항상 현재 모듈이 소유한 변수에 영향을 주거나, 그렇지 않으면 오류를 일으켜요.
as로 이름 바꾸기 (Renaming with as)
import나 using으로 스코프에 들어온 식별자는 as 키워드로 이름을 바꿀 수 있어요. 이름 충돌을 피하거나 이름을 줄일 때 유용하죠. 예를 들어 Base는 read라는 함수 이름을 내보내는데, CSV.jl 패키지도 CSV.read를 제공해요. CSV 읽기를 자주 호출한다면 CSV. 접두어를 떼는 게 편리할 거예요. 하지만 그러면 Base.read인지 CSV.read인지가 모호해져요.
julia> read;
julia> import CSV: read
WARNING: ignoring conflicting import of CSV.read into Main
이름 바꾸기가 해결책을 줍니다.
julia> import CSV: read as rd
가져온 패키지 자체도 이름을 바꿀 수 있어요.
import BenchmarkTools as BT
as는 단일 식별자를 스코프로 가져올 때만 using과 함께 동작해요. using CSV: read as rd는 되지만, using CSV as C는 CSV의 내보낸 이름 전체에 작용해서 안 돼요.
여러 using·import 문장 섞기
위 형태 중 어떤 것이든 using·import 문장을 여러 개 쓰면, 그 효과는 나타나는 순서대로 합쳐져요. 예를 들어,
julia> using .NiceStuff # 내보낸 이름과 모듈 이름
julia> import .NiceStuff: nice # 정규화하지 않은 함수에 메서드 추가 허용
는 NiceStuff의 모든 내보낸 이름과 모듈 이름 자신을 스코프로 가져오고, nice에 모듈 이름 접두어 없이 메서드를 추가하는 것도 허용해요.
이름 충돌 처리 (Handling name conflicts)
두 개(혹은 그 이상)의 패키지가 같은 이름을 내보내는 상황을 생각해 볼게요.
julia> module A
export f
f() = 1
end
A
julia> module B
export f
f() = 2
end
B
using .A, .B 문장은 동작하는데, f를 호출하려 하면 힌트와 함께 오류가 나요.
julia> using .A, .B
julia> f
ERROR: UndefVarError: `f` not defined in `Main`
Hint: It looks like two or more modules export different bindings with this name, resulting in ambiguity. Try explicitly importing it from a particular module, or qualifying the name with the module it should come from.
여기서 Julia는 어떤 f를 말하는지 결정할 수 없으니, 네가 선택해야 해요. 흔히 쓰는 해결책은 이렇습니다.
A.f와B.f처럼 정규화된 이름으로 그냥 진행한다. 특히f가 우연히 일치하지만 각 패키지에서 뜻이 다를 때 코드를 읽는 사람에게 맥락을 분명히 해줘요. 예를 들어degree는 수학, 자연과학, 일상생활에서 각각 다른 쓰임이 있으니 그 의미들을 분리해 두는 게 좋아요.- 위의
as키워드로 하나 또는 두 식별자의 이름을 바꾼다. 예를 들어
julia> using .A: f as f
julia> using .B: f as g
이렇게 하면 B.f를 g로 쓸 수 있어요. 여기서는 그 전에 using A를 쓰지 않았다고(그랬다면 f가 이미 네임스페이스에 들어왔을 테니까) 가정해요.
3. 문제가 되는 이름들이 정말로 뜻을 공유한다면, 한 모듈이 다른 모듈에서 그 이름을 가져오거나, 이런 인터페이스를 정의하는 일만 담당하는 가벼운 "기반(base)" 패키지를 두는 게 흔해요. 이런 패키지 이름은 관례상 ...Base로 끝나는 게 일반적이에요(Julia의 Base 모듈과는 무관).
정의의 우선순위 (Precedence order of definitions)
일반적으로 바인딩(binding) 정의에는 네 종류가 있어요.
using M을 통한 암묵적 가져오기로 제공되는 것- 명시적 가져오기로 제공되는 것(예:
using M: x,import M: x) - 전역으로 암묵적으로 선언되는 것(타입 지정 없는
global x) - 정의 문법으로 명시적으로 선언되는 것(
const,global x::T,struct등)
문법적으로 이것들은 세 단계의 우선순위로 나뉘어요(약한 것에서 강한 것 순).
- 암묵적 가져오기
- 암묵적 선언
- 명시적 선언과 가져오기
일반적으로 더 약한 바인딩을 더 강한 것으로 교체하는 건 허용돼요.
julia> module M1; const x = 1; export x; end
Main.M1
julia> using .M1
julia> x # M1로부터의 암묵적 가져오기
1
julia> begin; f() = (global x; x = 1) end
julia> x # 암묵적 선언
ERROR: UndefVarError: `x` not defined in `Main`
Suggestion: add an appropriate import or assignment. This global was declared but not assigned.
julia> const x = 2 # 명시적 선언
2
하지만 명시적 우선순위 단계 안에서의 교체는 문법적으로 허용되지 않아요.
julia> module M1; const x = 1; export x; end
Main.M1
julia> import .M1: x
julia> const x = 2
ERROR: cannot declare Main.x constant; it was already declared as an import
Stacktrace:
[1] top-level scope @ REPL[3]:1
아니면 무시되기도 해요.
julia> const y = 2
2
julia> import .M1: x as y
WARNING: import of M1.x into Main conflicts with an existing identifier; ignored.
암묵적 바인딩의 해석은 현재 월드 에이지(world age)에서 보이는 모든 using된 모듈의 집합에 달려 있어요. 세부 사항은 월드 에이지에 관한 매뉴얼 장을 참고하세요.
기본 최상위 정의와 bare module (Default top-level definitions and bare modules)
모듈은 자동으로 using Core, using Base, 그리고 그 모듈의 전역 스코프 안에서 표현식/파일을 평가하는 eval과 include 함수의 정의를 포함해요.
이런 기본 정의가 필요 없다면, 대신 baremodule 키워드로 모듈을 정의할 수 있어요(Core는 여전히 가져와진다는 점에 주의). baremodule을 기준으로 보면 표준 module은 이렇게 생겼어요.
baremodule Mod
using Base
eval(x) = Core.eval(Mod, x)
include(p) = Base.include(Mod, p)
...
end
Core조차 원하지 않는다면, 아무것도 가져오지 않고 어떤 이름도 정의하지 않는 모듈을 Module(:YourNameHere, false, false)로 정의할 수 있고, @eval이나 Core.eval로 그 안에 코드를 평가할 수 있어요.
julia> arithmetic = Module(:arithmetic, false, false)
Main.arithmetic
julia> @eval arithmetic add(x, y) = $(+)(x, y)
add (generic function with 1 method)
julia> arithmetic.add(12, 13)
25
표준 모듈 (Standard modules)
중요한 표준 모듈은 세 개가 있어요.
Core는 언어에 "내장된" 모든 기능을 담고 있어요.Base는 거의 모든 경우에 유용한 기본 기능을 담고 있어요.Main은 Julia를 시작했을 때의 최상위 모듈이자 현재 모듈이에요.
표준 라이브러리 모듈
기본적으로 Julia는 몇몇 표준 라이브러리 모듈을 함께 제공해요. 이들은 명시적으로 설치할 필요가 없다는 점만 빼면 일반 Julia 패키지처럼 동작해요. 예를 들어 단위 테스트를 하고 싶다면 Test 표준 라이브러리를 다음과 같이 불러올 수 있어요.
using Test
하위 모듈과 상대 경로 (Submodules and relative paths)
모듈은 *하위 모듈(submodule)*을 담을 수 있어서, 같은 module ... end 문법을 중첩해요. 이것들로 별도의 네임스페이스를 도입해 복잡한 코드베이스를 정리하는 데 도움이 될 수 있어요. 각 module은 자기만의 스코프를 도입하므로, 하위 모듈이 부모로부터 이름을 자동으로 "상속"하지는 않는다는 점을 기억하세요.
하위 모듈은 using·import 문장에서 *상대 모듈 한정자(relative module qualifier)*로 주변(감싸는) 부모 모듈 안의 다른 모듈을 참조하는 것이 권장돼요. 상대 모듈 한정자는 현재 모듈에 해당하는 점(.)으로 시작하고, 점이 하나씩 추가될 때마다 현재 모듈의 부모로 올라가요. 필요하면 그 뒤에 모듈을 이어 붙이고, 최종적으로 접근할 실제 이름이 오며, 모두 .로 구분돼요. 특별한 경우로, 모듈 루트를 참조할 때는 . 없이 쓸 수 있어서 그 모듈까지의 깊이를 셀 필요가 없어요.
다음 예시를 보세요. 하위 모듈 SubA가 함수를 정의하는데, 그 함수를 "형제" 모듈에서 확장하는 구조예요.
julia> module ParentModule
module SubA
export add_D # 내보낸 인터페이스
const D = 3
add_D(x) = x + D
end
using .SubA # `add_D`를 네임스페이스로 가져옴
export add_D # ParentModule에서도 내보냄
module SubB
import ..SubA: add_D # "형제" 모듈을 위한 상대 경로
# import ParentModule.SubA: add_D # 패키지 안에서는 위의 import와 동등하지만, REPL에서는 아님
struct Infinity end
add_D(x::Infinity) = x
end
end;
비슷한 상황에서 점 없이 import를 쓰는 코드를 패키지에서 볼 수도 있어요.
julia> import ParentModule.SubA: add_D
ERROR: ArgumentError: Package ParentModule not found in current path.
하지만 이건 코드 로딩을 통해서 작동하므로, ParentModule이 파일 안의 패키지에 있어야만 해요. ParentModule이 REPL에서 정의됐다면 상대 경로를 써야 해요.
julia> import .ParentModule.SubA: add_D
값을 평가할 때는 정의의 순서도 중요하다는 점에 주의하세요. 다음을 생각해 볼게요.
module TestPackage
export x, y
x = 0
module Sub
using ..TestPackage
z = y # ERROR: UndefVarError: `y` not defined in `Main`
end
y = 1
end
여기서 Sub는 TestPackage.y가 정의되기 전에 쓰려고 하니까 값을 갖지 못해요. 비슷한 이유로 순환 순서는 쓸 수 없어요.
module A
module B
using ..C # ERROR: UndefVarError: `C` not defined in `Main.A`
end
module C
using ..B
end
end
모듈 초기화와 프리컴파일 (Module initialization and precompilation)
큰 모듈은 로드하는 데 몇 초가 걸릴 수 있어요. 모듈의 모든 문장을 실행하는 일이 대량의 코드 컴파일을 수반하는 경우가 많기 때문이죠. Julia는 이 시간을 줄이려고 모듈의 프리컴파일(precompiled) 캐시를 만들어요.
프리컴파일된 모듈 파일("캐시 파일"이라고도 부름)은 import나 using으로 모듈을 불러올 때 자동으로 만들어지고 사용돼요. 캐시 파일이 아직 없다면 모듈이 컴파일되어 나중에 재사용할 수 있게 저장돼요. 모듈을 불러오지 않고 이 파일들을 만들려면 Base.compilecache(Base.identify_package("modulename"))를 수동으로 호출할 수도 있어요. 결과 캐시 파일은 DEPOT_PATH[1]의 compiled 하위 폴더에 저장돼요. 시스템에 아무 변화가 없으면 import나 using으로 모듈을 불러올 때 그런 캐시 파일이 사용돼요.
프리컴파일 캐시 파일은 모듈·타입·메서드·상수의 정의를 저장해요. 메서드 특수화(specialization)와 그에 생성된 코드도 저장할 수 있지만, 보통은 개발자가 명시적인 precompile 지시문을 추가하거나 패키지 빌드 중에 컴파일을 강제하는 작업부하를 실행해야 해요.
하지만 모듈의 의존성을 갱신하거나 소스 코드를 바꾸면, using이나 import 시에 모듈이 자동으로 다시 컴파일돼요. 의존성은 모듈이 가져오는 모듈, Julia 빌드, 포함하는 파일, 또는 모듈 파일에서 include_dependency(path)로 선언된 명시적 의존성들이에요.
include로 불러온 파일 의존성에서는 파일 크기(fsize)나 내용(해시로 축약됨)이 바뀌었는지 검사해 변경을 판단해요. include_dependency로 불러온 파일 의존성에서는 수정 시간(mtime)이 바뀌었는지, 혹은 가장 가까운 초로 절단한 수정 시간과 같은지 검사해 판단해요(하위 초 정확도로 mtime을 복사하지 못하는 시스템을 수용하려는 것). require의 검색 로직이 고른 파일 경로가 프리컴파일 파일을 만든 경로와 일치하는지도 고려해요. 또 현재 프로세스에 이미 불러온 의존성 집합을 고려해서, 실행 중인 시스템과 프리컴파일 캐시 사이에 비호환성이 생기지 않도록, 그 파일들이 바뀌거나 사라져도 그런 모듈은 다시 컴파일하지 않아요. 마지막으로 컴파일 타임 설정의 변경도 고려해요.
모듈이 프리컴파일하기에 안전하지 않다는 걸 안다면(예: 아래 설명된 이유 중 하나로), 모듈 파일에 (보통 맨 위에 놓는) __precompile__(false)를 넣어야 해요. 그러면 Base.compilecache가 오류를 던지고, using/import는 그 모듈을 현재 프로세스에 직접 불러와 프리컴파일과 캐싱을 건너뛰게 돼요. 그렇게 해서 그 모듈이 다른 프리컴파일 모듈에 의해 가져와지는 것도 막아주죠.
모듈을 작성할 때는 증분 공유 라이브러리를 만드는 데 내재된 몇몇 동작을 알아둘 필요가 있어요. 예를 들어 외부 상태는 보존되지 않아요. 이걸 감안하려면 런타임에 일어나야 하는 초기화 단계를 컴파일 타임에 일어날 수 있는 단계와 명시적으로 분리해야 해요. 이를 위해 Julia는 모듈에 __init__() 함수를 정의할 수 있게 해주는데, 런타임에 일어나야 하는 초기화 단계를 여기서 실행해요. 이 함수는 컴파일(--output-*) 중에는 호출되지 않아요. 실질적으로 이 함수는 코드의 수명 동안 정확히 한 번 실행될 거라 가정할 수 있어요. 필요하면 수동으로 호출할 수도 있지만, 기본 가정은 이 함수가 로컬 머신의 상태 계산을 처리하며 컴파일된 이미지에 포착될 필요가 없거나 오히려 포착되면 안 된다는 거예요. 이 함수는 모듈이 프로세스로 불러온 뒤(증분 컴파일 --output-incremental=yes로 불러오는 경우를 포함해, 전체 컴파일 프로세스로 불러오는 경우는 제외하고) 호출돼요.
특히 모듈에 function __init__()을 정의하면, Julia는 런타임에 모듈이 처음으로 불러온 직후에(예: import, using, 혹은 require로) __init__()을 호출해요(__init__은 정확히 한 번, 모듈의 모든 문장이 실행된 뒤에만 호출된다는 뜻). 모듈이 완전히 가져와진 뒤에 호출되기 때문에, 하위 모듈이나 다른 가져온 모듈의 __init__ 함수는 감싸는 모듈의 __init__보다 먼저 호출돼요. 이 순서는 스레드 간에도 동기화되어서, 의존성 순서대로 모든 __init__이 실행되어야 using 결과가 완료되는 방식으로 효과 순서를 안전하게 신뢰할 수 있어요. 다만 의존성이 아닌 다른 __init__ 메서드와는 동시에 실행될 수 있으니, 현재 모듈 밖의 공유 상태에 접근할 때는 필요한 경우 락을 쓰도록 주의해야 해요.
__init__의 전형적인 두 용도는 외부 C 라이브러리의 런타임 초기화 함수를 호출하는 것과, 외부 라이브러리가 반환하는 포인터를 포함하는 전역 상수를 초기화하는 거예요. 예를 들어 런타임에 foo_init() 초기화 함수를 호출해야 하는 C 라이브러리 libfoo를 호출한다고 해 볼게요. 또 libfoo가 정의한 void *foo_data() 함수의 반환값을 담는 전역 상수 foo_data_ptr를 정의하고 싶다고 해 보죠. 이 상수는 포인터 주소가 실행마다 바뀌므로 런타임(컴파일 타임이 아니라)에 초기화해야 해요. 모듈에 다음과 같은 __init__ 함수를 정의하면 됩니다.
const foo_data_ptr = Ref{Ptr{Cvoid}}(0)
function __init__()
ccall((:foo_init, :libfoo), Cvoid, ())
foo_data_ptr[] = ccall((:foo_data, :libfoo), Ptr{Cvoid}, ())
nothing
end
__init__ 같은 함수 안에서 전역을 정의하는 게 완전히 가능하다는 점을 보세요. 이건 동적 언어를 쓰는 장점 중 하나예요. 하지만 전역 스코프에서 상수로 만들면 그 타입이 컴파일러에 알려져 더 잘 최적화된 코드를 만들 수 있게 해줘요. 당연히 foo_data_ptr에 의존하는 모듈의 다른 전역도 __init__에서 초기화해야 해요.
ccall로 만들어지지 않은 대부분의 Julia 객체를 포함한 상수는 __init__에 둘 필요가 없어요. 그 정의는 프리컴파일되어 캐시된 모듈 이미지에서 불러올 수 있으니까요. 배열처럼 복잡한 힙 할당 객체도 여기에 포함돼요. 하지만 원시 포인터 값을 반환하는 루틴은 프리컴파일이 작동하려면 런타임에 호출해야 해요(Ptr 객체는 isbits 객체 안에 숨겨져 있지 않으면 널 포인터로 변해요). 여기에는 Julia 함수 @cfunction과 pointer의 반환값도 포함돼요.
프리컴파일을 쓸 때는 컴파일 단계와 실행 단계의 구분을 분명히 인지하는 게 중요해요. 이 모드에서는 Julia가 임의의 Julia 코드를 실행하는 것을 허용하는 컴파일러이지, 컴파일된 코드도 생성하는 독립형 인터프리터가 아니라는 게 훨씬 분명히 드러나요.
알려진 다른 잠재적 실패 시나리오는 다음과 같아요.
- 전역 카운터(예: 객체를 유일하게 식별하려는 시도). 다음 코드 조각을 보세요.
mutable struct UniquedById
myid::Int
let counter = 0
UniquedById() = new(counter += 1)
end
end
이 코드의 의도는 모든 인스턴스에 유일한 id를 주는 것이지만, 카운터 값은 컴파일 끝에 기록돼요. 이 증분 컴파일 모듈의 이후 모든 사용은 그 같은 카운터 값에서 시작해요.
(메모리 포인터를 해싱해서 동작하는) objectid에도 비슷한 문제가 있다는 점에 주의하세요(아래 Dict 사용에 관한 주석 참조). 한 가지 대안은 매크로로 @__MODULE__를 포착해 현재 counter 값과 함께 저장하는 것이지만, 그런 전역 상태에 의존하지 않도록 코드를 재설계하는 편이 더 나을 수도 있어요.
- 연관 컬렉션(
Dict,Set같은)은__init__에서 다시 해시되어야 해요. (미래에는 초기화 함수를 등록하는 메커니즘이 제공될 수도 있어요.) - 컴파일 타임 부작용이 로드 타임까지 지속될 것에 의존하는 경우. 예로는 다른 Julia 모듈의 배열이나 변수를 수정하기, 열린 파일·장치 핸들 유지하기, 다른 시스템 자원(메모리 포함)의 포인터 저장하기 등이 있어요.
- 다른 모듈의 전역 상태를 조회 경로 대신 직접 참조해서 실수로 "복사본"을 만드는 경우. 예를 들어 (전역 스코프에서):
#mystdout = Base.stdout #= 이 모듈로 Base.stdout을 복사하므로 제대로 동작하지 않음 =#
# 접근자 함수를 대신 사용:
getstdout() = Base.stdout #= 가장 좋은 선택 =#
# 또는 런타임으로 할당을 옮기기:
__init__() = global mystdout = Base.stdout #= 이것도 동작 =#
사용자가 다른 잘못된 동작 상황을 피하도록, 코드를 프리컴파일하는 동안 수행할 수 있는 연산에는 몇 가지 추가 제한이 걸려 있어요.
- 다른 모듈에서 부작용을 일으키려고
eval을 호출하는 것. 증분 프리컴파일 플래그가 켜 있으면 경고도 발생해요. __init__()이 시작된 뒤 로컬 스코프에서의global const문장(이에 대한 오류 추가 계획은 issue #12010 참조).- 증분 프리컴파일을 하는 동안 모듈을 교체하는 것은 런타임 오류예요.
알아둘 몇 가지 다른 점:
- 소스 파일 자체를 바꾼 뒤에는(
Pkg.update로 인한 것 포함) 코드 재로딩/캐시 무효화가 수행되지 않고,Pkg.rm후에 정리도 되지 않아요. - 형태가 바뀐(reshaped) 배열의 메모리 공유 동작은 프리컴파일에서 무시돼요(각 뷰가 자기 복사본을 받아요).
- 컴파일 타임과 런타임 사이에 파일시스템이 변하지 않을 것을 기대하는 것. 예를 들어 런타임에 자원을 찾기 위해
@__FILE__/source_path()를 쓰거나, BinDeps의@checked_lib매크로를 쓰는 경우. 때로는 피할 수 없어요. 하지만 가능하면 자원을 컴파일 타임에 모듈로 복사해서 런타임에 찾을 필요가 없게 하는 게 좋은 관행이에요. WeakRef객체와 파이널라이저(finalizer)는 현재 직렬화기가 제대로 처리하지 못해요(다음 릴리스에서 고쳐질 예정).Method,MethodInstance,MethodTable,TypeMapLevel,TypeMapEntry같은 내부 메타데이터 객체의 인스턴스나 그 필드에 대한 참조를 포착하는 건 대개 피하는 게 좋아요. 그렇게 하면 직렬화기를 혼란스럽게 하고 원하는 결과를 얻지 못할 수 있거든요. 반드시 오류인 건 아니지만, 시스템이 이것들 중 일부를 복사하고 다른 일부는 단일 유일 인스턴스로 만들려 한다는 점을 각오해야 해요.
모듈을 개발하는 동안 증분 프리컴파일을 끄는 게 도움이 될 때가 있어요. 커맨드라인 플래그 --compiled-modules={yes|no|existing}로 모듈 프리컴파일을 켜고 끌 수 있어요. --compiled-modules=no로 Julia를 시작하면 모듈과 모듈 의존성을 불러올 때 컴파일 캐시의 직렬화된 모듈이 무시돼요. 어떤 경우에는 기존 프리컴파일 모듈을 불러오되 새로 만들지는 않기를 원할 수도 있는데, --compiled-modules=existing로 시작하면 돼요. 더 세밀한 제어는 --pkgimages={yes|no|existing}로 가능한데, 이건 프리컴파일 중 네이티브 코드 저장에만 영향을 줘요. Base.compilecache는 여전히 수동으로 호출할 수 있어요. 이 커맨드라인 플래그의 상태는 Pkg.build에 전달되어, 패키지를 설치·갱신·명시적으로 빌드할 때 자동 프리컴파일 트리거를 비활성화해요.
환경 변수로 일부 프리컴파일 실패를 디버깅할 수도 있어요. JULIA_VERBOSE_LINKING=true로 설정하면 컴파일된 네이티브 코드의 공유 라이브러리 링킹 실패를 해결하는 데 도움이 될 수 있어요. Julia 매뉴얼의 Developer Documentation 부분에서 "Package Images" 아래 Julia 내부를 다루는 절에 더 자세한 내용이 있어요.
더 알아보기
- Julia의 전역 스코프·변수와 스코프 문서
- 코드 로딩 (Code Loading) 매뉴얼
using/import의 정확한 문법은 Julia Base 문서