D 언어 공식 사양: Modules
D 언어 공식 사양: Modules (모듈)
D에서 프로그램을 여러 파일로 나눌 때, 각 파일이 곧 하나의 모듈이 돼요. 이번 장에서는 모듈을 어떻게 선언하고, 다른 모듈의 코드를 어떻게 가져다 쓰는지(임포트), 그리고 모듈 단위로 초기화·정리 작업을 어떻게 예약하는지를 하나씩 풀어볼게요. 이 장의 주인공은 세 가지예요 — 어떻게 모듈을 정의하는가(Modeclaration), 어떻게 남의 모듈을 불러오는가(ImportDeclaration), 그리고 모듈 수명주기와 관련된 각종 훅이죠.
본문
개요 (Overview)
모듈(Module)은 소스 파일과 일대일로 대응해요. ModuleDeclaration로 이름을 명시적으로 정해주지 않으면, 모듈 이름은 파일 이름에서 경로와 확장자를 뗀 값이 기본값이 돼요.
모듈 이름은 자동으로 그 내용물의 네임스페이스 스코프 역할을 해요. 모듈은 겉보기에 struct와 비슷해 보이지만, 결정적으로 다른 점이 몇 가지 있어요.
- 모듈 인스턴스는 단 하나만 존재하며, 정적(static)으로 할당돼요.
- 하나의 소스 파일에는 모듈이 단 하나만 들어갈 수 있어요.
- 모듈 안의 심볼은 임포트해서 가져올 수 있어요.
- 모듈은 항상 전역 스코프에서 컴파일되며, 저장 클래스(storage class)나 다른 수식자(modifier)의 영향을 받지 않아요.
모듈은 패키지(package) 라고 부르는 계층 구조로 묶을 수 있어요.
모듈은 몇 가지 보장(guarantee)을 해줘요.
- 모듈이 임포트되는 순서는 그 의미(semantics)에 영향을 주지 않아요.
- 모듈의 의미는 어느 스코프에서 임포트되느냐에도 영향을 받지 않아요.
- 어떤 모듈
C가 모듈A와B를 임포트할 때,B에 대한 수정이A에 의존하는C의 코드를 조용히 바꿔버리지 않아요.
문법 (Grammar)
Module:
ModuleDeclaration
ModuleDeclarationopt DeclDefs
DeclDefs:
DeclDef
DeclDef DeclDefs
DeclDef:
AttributeSpecifier
AliasAssign
Declaration
AggregateMember
UnitTest
StaticConstructor
StaticDestructor
SharedStaticConstructor
SharedStaticDestructor
DebugSpecification
VersionSpecification
MixinDeclaration
EmptyDeclaration
AggregateMember:
Constructor
NewDeclaration
Destructor
Postblit
Invariant
AliasThis
EmptyDeclaration:
;
Module은 보통 하나 이상의 DeclDef 선언을 가져요.
Declaration은 함수 본문 안에서Statement로 허용되는 유일한DeclDef예요.DeclDef는 또한 aggregate 타입 선언과 템플릿의 멤버이기도 해요.AggregateMember는 오직 aggregate 타입 멤버로만 의미상 유효해요. (구문적으로는 aggregate 타입 밖에서도 허용되긴 하지만요.)
모듈 선언 (Module Declaration)
ModuleDeclaration은 모듈의 이름과 그것이 속한 패키지를 정해요. 이것이 없으면 모듈 이름은 (경로와 확장자를 뗀) 소스 파일의 이름으로 간주돼요.
ModuleDeclaration:
ModuleAttributesopt module ModuleFullyQualifiedName Editionopt ;
ModuleAttributes:
ModuleAttribute
ModuleAttribute ModuleAttributes
ModuleAttribute:
DeprecatedAttribute
UserDefinedAttribute
ModuleFullyQualifiedName:
ModuleName
Packages . ModuleName
ModuleName:
Identifier
Packages:
PackageName
Packages . PackageName
PackageName:
Identifier
가장 오른쪽 Identifier 앞에 오는 Identifier들이 바로 그 모듈이 속한 Packages예요. 패키지는 소스 파일 경로의 디렉터리 이름에 대응해요. 패키지 이름과 모듈 이름은 Keyword가 될 수 없어요.
ModuleDeclaration이 있으면 그 선언은 소스 파일에서 첫 번째이자 유일한 그런 선언이어야 하고, 앞에는 주석과 #line 지시문만 올 수 있어요.
예를 들면:
module c.stdio; // module stdio in the c package
관례상 패키지·모듈 이름은 모두 소문자로 써요. 이 이름들이 운영체제의 디렉터리·파일 이름과 일대일로 대응하고, 많은 파일 시스템이 대소문자를 구분하지 않기 때문이에요. 패키지·모듈 이름을 모두 소문자로 쓰면 서로 다른 파일 시스템 사이로 프로젝트를 옮길 때 문제를 피하거나 최소화할 수 있어요.
만약 모듈 파일 이름이 유효한 모듈 이름이 아니라면 (예: foo-bar.d), 모듈 선언으로 유효한 이름을 정해주면 돼요.
module foo_bar;
구현 정의 (Implementation Defined):
- 패키지·모듈 식별자를 디렉터리·파일 이름으로 매핑하는 방식.
모범 사례 (Best Practices):
PackageName과ModuleName은 최대한 많은 파일 시스템과의 이식성·호환성을 보장하도록 ASCII 소문자·숫자·_로만 구성하는 것이 좋아요.- 패키지·모듈의 파일 이름 역시 ASCII 소문자·숫자·
_로만 구성하고,Keyword가 되지 않게 해야 해요.
Deprecated 모듈 (Deprecated modules)
ModuleDeclaration에는 선택적으로 DeprecatedAttribute를 붙일 수 있어요. 컴파일러는 deprecated 모듈이 임포트될 때 메시지를 출력해요.
deprecated module foo;
module bar;
import foo; // Deprecated: module foo is deprecated
DeprecatedAttribute에는 선택적으로 AssignExpression 인자를 넣어 더 자세한 메시지를 줄 수도 있어요. 이 AssignExpression은 컴파일 타임에 문자열로 평가되어야 해요.
deprecated("Please use foo2 instead.")
module foo;
module bar;
import foo; // Deprecated: module foo is deprecated - Please use foo2 instead.
임포트 선언 (Import Declaration)
한 모듈의 심볼을 다른 모듈에서 사용할 수 있게 하려면 ImportDeclaration을 써요.
ImportDeclaration:
import ImportList ;
static import ImportList ;
ImportList:
Import
ImportBindings
Import , ImportList
Import:
ModuleFullyQualifiedName
ModuleAliasIdentifier = ModuleFullyQualifiedName
ImportBindings:
Import : ImportBindList
ImportBindList:
ImportBind
ImportBind , ImportBindList
ImportBind:
Identifier
Identifier = Identifier
ModuleAliasIdentifier:
Identifier
ImportDeclaration에는 여러 형태가 있어요. 일반적인 임포트에서 시작해 점점 세밀한 임포트로 이어지죠.
ImportDeclaration이 나타나는 순서는 아무 의미가 없어요.
ImportDeclaration 안의 ModuleFullyQualifiedName은 그것이 속한 패키지까지 전부 자격을 갖춘(fully qualified) 형태여야 해요. 임포트하는 모듈에 대해 상대적(relative)이라고 간주되지 않아요.
심볼 이름 탐색 (Symbol Name Lookup)
가장 단순한 임포트 형태는, 임포트할 모듈들을 나열해 주기만 하면 되는 거예요.
module myapp.main;
import std.stdio; // import module stdio from package std
class Foo : BaseClass
{
import myapp.foo; // import module myapp.foo in this class' scope
void bar ()
{
import myapp.bar; // import module myapp.bar in this function' scope
writeln("hello!"); // calls std.stdio.writeln
}
}
심볼 이름이 자격(qualification) 없이 쓰이면 2단계 탐색(two-phase lookup) 이 일어나요. 먼저 모듈 스코프를 가장 안쪽 스코프부터 검색해요. 앞의 예에서 writeln을 찾을 때의 순서는 이렇게 돼요.
bar안의 선언들.Foo안의 선언들.BaseClass안의 선언들.- 모듈 스코프의 선언들.
첫 탐색이 실패하면, 임포트를 대상으로 두 번째 탐색이 수행돼요. 이 두 번째 탐색 단계에서는 상속된 스코프가 무시돼요. 여기에는 베이스 클래스·인터페이스의 스코프(앞의 예에서 BaseClass의 임포트가 무시되는 것)뿐 아니라, mixin된 template 안의 임포트까지 포함돼요.
심볼 탐색은 일치하는 심볼을 찾는 즉시 멈춰요. 같은 탐색 단계에서 같은 이름의 심볼이 둘 발견되면, 이 모호함은 컴파일 오류가 돼요.
module A;
void foo();
void bar();
module B;
void foo();
void bar();
module C;
import A;
void foo();
void test()
{
foo(); // C.foo() is called, it is found before imports are searched
bar(); // A.bar() is called, since imports are searched
}
module D;
import A;
import B;
void test()
{
foo(); // error, A.foo() or B.foo() ?
A.foo(); // ok, call A.foo()
B.foo(); // ok, call B.foo()
}
module E;
import A;
import B;
alias foo = B.foo;
void test()
{
foo(); // call B.foo()
A.foo(); // call A.foo()
B.foo(); // call B.foo()
}
public 임포트 (Public Imports)
기본적으로 임포트는 private예요. 즉 모듈 A가 모듈 B를 임포트하고, 모듈 B가 모듈 C를 임포트한다면, C 안의 이름들은 B 안에서만 보이고 A 안에서는 보이지 않아요.
임포트는 명시적으로 public으로 선언할 수 있는데, 그러면 임포트된 모듈의 이름들이 더 나아간 임포트에서도 보이게 돼요. 아까의 예에서 모듈 A가 모듈 B를 임포트하고, 모듈 B가 모듈 C를 public하게 임포트한다면, C의 이름들이 A에서도 보여요.
public하게 임포트된 모듈의 모든 심볼은 임포트하는 모듈에서 별칭(alias)으로도 만들어져요. 그래서 앞의 예에서 C가 이름 foo를 담고 있다면, A에서 foo, B.foo, C.foo로 접근할 수 있어요.
다른 예를 볼게요.
module W;
void foo() { }
module X;
void bar() { }
module Y;
import W;
public import X;
...
foo(); // calls W.foo()
bar(); // calls X.bar()
module Z;
import Y;
...
foo(); // error, foo() is undefined
bar(); // ok, calls X.bar()
X.bar(); // ditto
Y.bar(); // ok, Y.bar() is an alias to X.bar()
static 임포트 (Static Imports)
static 임포트는 모듈의 이름을 참조할 때 반드시 완전 자격(full qualified) 이름을 요구해요.
static import std.stdio;
void main()
{
writeln("hello!"); // error, writeln is undefined
std.stdio.writeln("hello!"); // ok, writeln is fully qualified
}
이름 바꿔 임포트 (Renamed Imports)
임포트에 지역 이름을 줄 수 있어요. 그러면 모듈의 모든 심볼 참조가 그 지역 이름으로 자격을 갖춰야 해요.
import io = std.stdio;
void main()
{
io.writeln("hello!"); // ok, calls std.stdio.writeln
std.stdio.writeln("hello!"); // error, std is undefined
writeln("hello!"); // error, writeln is undefined
}
선택적 임포트 (Selective Imports)
모듈에서 특정 심볼만 골라서 현재 네임스페이스에 묶어(bind) 가져올 수 있어요.
import std.stdio : writeln, foo = write;
void main()
{
std.stdio.writeln("hello!"); // error, std is undefined
writeln("hello!"); // ok, writeln bound into current namespace
write("world"); // error, write is undefined
foo("world"); // ok, calls std.stdio.write()
fwritefln(stdout, "abc"); // error, fwritefln undefined
}
static은 선택적 임포트와 함께 쓸 수 없어요.
이름 바꾸기 + 선택적 임포트 (Renamed and Selective Imports)
이름 바꾸기와 선택적 임포트를 함께 쓰면 이렇게 돼요.
import io = std.stdio : foo = writeln;
void main()
{
writeln("bar"); // error, writeln is undefined
std.stdio.foo("bar"); // error, foo is bound into current namespace
std.stdio.writeln("bar"); // error, std is undefined
foo("bar"); // ok, foo is bound into current namespace,
// FQN not required
io.writeln("bar"); // ok, io=std.stdio bound the name io in
// the current namespace to refer to the entire
// module
io.foo("bar"); // error, foo is bound into current namespace,
// foo is not a member of io
}
스코프 임포트 (Scoped Imports)
임포트 선언은 어떤 스코프에서든 쓸 수 있어요. 예를 들면:
void main()
{
import std.stdio;
writeln("bar");
}
임포트들은 그 스코프에서 해결되지 않은 심볼들을 채우기 위해 탐색돼요. 지역에서 임포트된 심볼은 바깥 스코프에서 임포트된 심볼을 가릴(hide) 수 있어요.
함수 스코프에서는, 임포트된 심볼이 임포트 선언이 함수 본문에 어휘적으로 나타난 뒤에야 보이게 돼요. 다시 말해, 함수 스코프에서 임포트된 심볼은 앞으로 참조(forward reference)할 수 없어요.
void main()
{
void writeln(string) {}
void foo()
{
//std.stdio.writeln("stdio"); // error, `std` is undefined
writeln("main"); // calls `main.writeln`
import std.stdio;
writeln("main"); // still calls `main.writeln`
std.stdio.writeln("stdio"); // `std` is now in scope
void writeln(string) {}
writeln("foo"); // calls `main.foo.writeln`
}
foo();
writeln("main"); // calls `main.writeln`
//std.stdio.writeln("stdio"); // error, `std` is undefined
}
모듈 스코프 연산자 (Module Scope Operator)
식별자 앞에 점(.)을 하나 붙이면, 그 식별자를 모듈 스코프에서 탐색하게 해요.
const int x = 1;
void main()
{
int x = 5;
assert(x == 5); // main.x, not global x
assert(.x == 1); // global x
}
정적 생성과 파괴 (Static Construction and Destruction)
정적 생성자(static constructor)는 모듈의 상태를 초기화하기 위해 실행되고, 정적 파괴자(static destructor)는 모듈의 상태를 정리해요.
모듈은 정적 생성자·정적 파괴자를 여러 개 가질 수 있어요. 정적 생성자는 어휘적 순서(lexical order)로 실행되고, 정적 파괴자는 어휘적 순서의 역순으로 실행돼요.
공유되지 않는(non-shared) 정적 생성자·파괴자는 스레드가 생성되거나 파괴될 때마다 실행돼요. 메인 스레드도 포함해서요.
공유(shared) 정적 생성자는 main()이 호출되기 전에 한 번 실행돼요. 공유 정적 파괴자는 main() 함수가 반환된 뒤에 실행돼요.
import resource;
Resource x;
shared Resource y;
__gshared Resource z;
static this() // non-shared static constructor
{
x = acquireResource();
}
shared static this() // shared static constructor
{
y = acquireSharedResource();
z = acquireSharedResource();
}
static ~this() // non-shared static destructor
{
releaseResource(x);
}
shared static ~this() // shared static destructor
{
releaseSharedResource(y);
releaseSharedResource(z);
}
- 공유 정적 생성자·파괴자는 공유 전역 데이터를 초기화·정리하는 데 쓰여요.
- 공유되지 않는 정적 생성자·파괴자는 스레드 지역(thread local) 데이터를 초기화·정리하는 데 쓰여요.
정적 생성 순서 (Order of Static Construction)
모든 모듈의 공유(shared) 정적 생성자는, 공유되지 않는 정적 생성자보다 먼저 실행돼요.
정적 초기화의 순서는 각 모듈의 import 선언에 의해 암묵적으로 결정돼요. 각 모듈은 자기와 import된 모듈들이 먼저 정적으로 생성된다고 가정해요. 모듈 정적 생성자의 실행 순서에 그 밖의 다른 제약은 없어요.
import 선언의 순환(순환 의존, circular dependency)은 두 모듈 중 하나만, 또는 어느 쪽도 정적 생성자·정적 파괴자를 가지지 않는다면 허용돼요. 이 규칙을 위반하면 런타임 예외가 발생해요.
구현 정의 (Implementation Defined):
- 구현체는 순환 탐지 중단(cycle detection abort)을 재정의할 수단을 제공할 수 있어요. 대표적인 방법은 D 런타임 스위치
--DRT-oncycle=...을 쓰는 거예요. 지원되는 동작은 다음과 같아요.abort— 기본 동작. 앞 절에서 설명한 일반적인 동작이에요.print— 감지된 모든 순환을 출력하되 실행은 중단하지 않아요. 순환이 있을 때 정적 생성 순서는 구현 정의에 맡겨지고, 유효함이 보장되지 않아요.ignore— 실행을 중단하지도, 순환을 출력하지도 않아요. 순환이 있을 때 정적 생성 순서는 구현 정의에 맡겨지고, 유효함이 보장되지 않아요.
모범 사례 (Best Practices):
- 가능하면 순환 import는 피하세요. 순환 import는 프로그램 구조를 독립된 모듈로 분해하는 과정이 잘못됐다는 신호일 수 있어요. 서로를 import하는 두 모듈은 종종 순환이 없는 세 모듈로 재구성할 수 있는데, 그중 세 번째 모듈이 나머지 둘에 필요한 선언들을 담으면 돼요.
모듈 내부에서의 정적 생성 순서 (Order of Static Construction within a Module)
모듈 안에서는 정적 생성이 나타나는 어휘적 순서대로 일어나요.
정적 파괴 순서 (Order of Static Destruction)
정적 파괴는 정적 생성의 정확히 역순으로 정의돼요. 개별 모듈의 정적 파괴자는, 대응하는 정적 생성자가 성공적으로 완료된 경우에만 실행돼요.
공유 정적 파괴자는 정적 파괴자 이후에 실행돼요.
유닛 테스트 순서 (Order of Unit tests)
유닛 테스트는 모듈 안에 나타나는 어휘적 순서대로 실행돼요.
Mixin 선언 (Mixin Declaration)
MixinDeclaration:
mixin ( ArgumentList ) ;
ArgumentList의 각 AssignExpression은 컴파일 타임에 평가되고, 결과는 문자열로 표현될 수 있어야 해요. 결과 문자열들을 이어붙여 하나의 문자열을 만들고, 그 문자열의 텍스트 내용은 유효한 DeclDef로 컴파일될 수 있어야 하며, 실제로 그렇게 컴파일돼요.
mixin의 내용은 같은 스코프의 다른 DeclDef가 앞으로 참조(forward reference)할 수 없어요. 아직 AST로 끌어들여지지 않았기 때문이에요.
class B : A {} // Error: undefined identifier `A`
mixin ("class A {}");
앞으로 참조는 함수 본문에서만 동작할 수 있어요. 함수 본문에서는 이 선언들이 처리된 뒤에 진행되기 때문이에요.
void v()
{
class B : A {}
}
mixin ("class A {}");
패키지 모듈 (Package Module)
패키지 모듈(package module)은 다른 모듈을 public하게 임포트하면서 더 간단한 임포트 구문을 제공하는 데 써요. 이를 통해 기존 코드를 깨지 않고도 모듈 하나를 모듈들의 패키지로 바꿀 수 있어요. 라이브러리 모듈들의 예를 볼게요.
module libweb.client;
void runClient() { }
module libweb.server;
void runServer() { }
module libweb;
public import libweb.client;
public import libweb.server;
패키지 모듈의 파일 이름은 반드시 package.d여야 해요. 모듈 이름은 패키지의 완전 자격 이름(fully qualified name)으로 선언돼요. 패키지 모듈도 다른 모듈들과 똑같이 임포트할 수 있어요.
module test;
// import the package module
import libweb;
void main()
{
runClient();
runServer();
}
패키지 모듈은 하위 패키지(sub-package) 안에 중첩될 수도 있어요.
// must be declared as the fully qualified name of the package, not just 'utils'
module libweb.utils;
// publicly import modules from within the 'libweb.utils' package.
public import libweb.utils.conv;
public import libweb.utils.text;
그 패키지 모듈은 표준 모듈 임포트 선언으로 가져올 수 있어요.
module test;
// import the package module
import libweb.utils;
void main() { }
더 알아보기 (Learn more)
- D 언어 공식 사양 전체 목차: https://dlang.org/spec/langref.html
- 모듈·패키지와 관련된 챕터: Declarations, Conditional Compilation, Mixins