`Code`

Code

코드 컴파일, 코드 평가(evaluation), 코드 로딩을 관리하는 유틸리티들을 제공하는 모듈이에요.

이 모듈은 Erlang의 :code 모듈을 보완해 Elixir에 특화된 동작을 추가합니다. Elixir의 AST를 (평가하지 않고) 조작하는 함수가 필요하다면 Macro 모듈을 보세요.

출처: Code

본문

Code 모듈은 크게 보면 "컴파일·평가·로딩"이라는 세 가지 영역을 도와주는 도구함이에요. 개발할 때 파일을 컴파일하고, 문자열이나 코드 조각을 즉석으로 평가하며, 모듈이 로드됐는지 확인하고, 컴파일 경로를 관리하는 일까지 폭넓게 담당합니다.

파일 다루기

이 모듈에는 파일을 컴파일하고 평가하는 세 가지 함수가 있어요. 각각의 동작을 정리하면 다음과 같습니다:

  • require_file/2 — 파일을 컴파일하고 그 이름을 추적합니다. 이전에 require된 파일은 다시 컴파일하지 않아요.
  • compile_file/2 — 이름을 추적하지 않고 파일을 컴파일합니다. 여러 번 호출하면 여러 번 컴파일돼요.
  • eval_file/2 — 이름을 추적하지 않고 파일 내용을 평가합니다. 파일에 정의된 모듈 대신 파일의 마지막 표현식 결과를 반환해요. 평가된 파일은 다음 섹션에서 다루는 컴파일 트레이서를 촉발하지 않습니다.

요컨대 첫 번째 함수는 시스템이 다루는 파일을 추적해 같은 파일이 여러 번 컴파일되는 것을 피하고 싶을 때 씁니다. 스크립트에서 흔히 사용하죠.

compile_file/2는 추적 없이 파일에 정의된 모듈에 관심이 있을 때, eval_file/2는 파일이 정의하는 모듈보다 파일을 평가한 결과에 관심이 있을 때 사용해야 해요.

위 함수들은 Elixir 소스와 동작합니다. 바이트코드로 컴파일된 모듈(.beam 확장자를 가지며 보통 Mix 프로젝트의 _build 디렉터리 아래 위치)을 다루고 싶다면 Erlang의 :code 모듈의 함수를 참고하세요.

Erlang VM에서의 코드 로딩

Erlang은 코드를 로드하는 두 가지 모드가 있어요. interactive와 embedded입니다.

기본적으로 Erlang VM은 interactive 모드로 실행되어 모듈이 필요할 때 로드돼요. embedded 모드는 그 반대로, 모든 모듈을 미리 또는 명시적으로 로드해야 합니다.

ensure_loaded/1(그리고 ensure_loaded?/1, ensure_loaded!/1)로 모듈을 사용하기 전에 로드됐는지 확인하고 행동할 수 있어요.

ensure_compiled/1ensure_compiled!/1

Elixir에는 ensure_loaded/1의 상위 집합인 ensure_compiled/1ensure_compiled!/1 함수도 있어요.

Elixir의 컴파일은 병렬로 진행되므로, 어떤 상황에서는 아직 컴파일되지 않은(그래서 로드조차 할 수 없는) 모듈을 사용해야 할 수 있어요.

호출되면 ensure_compiled/1ensure_compiled!/1은 해당 모듈을 사용할 수 있을 때까지 호출자의 컴파일을 멈춥니다. ensure_compiled/1ensure_compiled!/1의 구분이 중요한 이유가 여기 있어요. ensure_compiled!/1을 쓰면 "그 모듈이 있어야만 계속할 수 있다"는 것을 컴파일러에 알리는 겁니다.

Code.ensure_compiled/1을 쓴다면 모듈 없이도 계속할 수 있다는 뜻이고, 그래서 Elixir는 아직 모듈을 사용할 수 없는 경우(나중에는 가능할 수 있지만) {:error, :unavailable}을 반환할 수 있어요.

그런 이유로 개발자들은 보통 Code.ensure_compiled!/1을 사용해야 합니다. 특히 이렇게 하지 마세요:

case Code.ensure_compiled(module) do
  {:module, _} -> module
  {:error, _} -> raise ...
end

마지막으로 ensure_compiled!/1은 같은 프로젝트 안에 정의된 모듈을 확인할 때만 필요하다는 점을 기억하세요. 의존성의 모듈에는 적용되지 않는데, 의존성은 항상 미리 컴파일되기 때문이에요.

대부분의 경우 ensure_loaded/1이면 충분합니다. ensure_compiled!/1은 드문 경우, 보통 콜백 정보를 위해 모듈을 호출해야 하는 매크로에서 사용해야 해요. ensure_compiled/1의 사용은 더 드뭅니다.

컴파일 트레이서

Elixir는 컴파일 트레이서를 지원합니다. 이를 통해 모듈이 파일 컴파일 시 Elixir 컴파일러가 다루는 구조들을 관찰할 수 있어요. 트레이서는 trace/2 함수를 구현하는 모듈입니다. 이 함수는 첫 번째 인자로 이벤트 이름을, 두 번째로 Macro.Env를 받고 :ok을 반환해야 합니다. 트레이서는 동기적으로 가능한 한 적은 작업을 하고, 작업의 대부분을 별도 프로세스로 위임하는 것이 매우 중요해요. 느린 트레이서는 컴파일을 느리게 합니다.

트레이서 목록은 put_compiler_option/2로 설정할 수 있어요. 트레이서에 제공되는 이벤트에는 다음과 같은 것들이 있습니다:

  • :start — (v1.11.0부터) 컴파일러가 새 어휘 문맥(lexical context)의 추적을 시작할 때마다 호출. 새 파일을 컴파일하거나 함수 안에서 모듈을 정의할 때 어휘 문맥이 시작돼요.
  • :stop — (v1.11.0부터) 컴파일러가 새 어휘 문맥(예: 새 파일)의 추적을 멈출 때 호출.
  • {:import, meta, module, opts}module이 import될 때마다 추적.
  • {:imported_function, meta, module, name, arity}{:imported_macro, meta, module, name, arity} — import된 함수나 매크로가 호출될 때마다 추적.
  • {:imported_quoted, meta, module, name, [arity]} — import된 함수나 매크로가 quote/2 안에서 처리될 때마다 추적.
  • {:alias, meta, alias, as, opts}aliasas로 alias될 때마다 추적.
  • {:alias_expansion, meta, as, alias} — 이전에 정의된 alias에 대한 alias 확장이 있을 때마다 추적.
  • {:alias_reference, meta, module} — 코드에 alias가 있을 때마다(즉 사용자가 MyModule.Foo.Bar를 쓸 때마다) 추적.
  • {:require, meta, module, opts}module이 require될 때마다 추적.
  • {:struct_expansion, meta, module, keys}module의 구조체가 확장될 때마다 추적.
  • {:remote_function, meta, module, name, arity}{:remote_macro, meta, module, name, arity} — 원격 함수나 매크로가 참조될 때마다 추적.
  • {:local_function, meta, name, arity}{:local_macro, meta, name, arity} — 로컬 함수나 매크로가 참조될 때마다 추적.
  • {:compile_env, app, path, return}Application.compile_env/3이나 Application.compile_env!/2가 호출될 때마다 추적.
  • :defmodule — (v1.16.2부터) 모듈의 정의가 시작되는 즉시 추적.
  • {:on_module, bytecode, _ignore} — (v1.13.0부터) 모듈이 정의될 때마다 추적. @after_compile 콜백과 동등하며 주어진 모듈의 모든 @after_compile 이후에 호출.

:tracers 컴파일러 옵션은 :parser_options 컴파일러 옵션과 결합해 추적된 이벤트의 메타데이터를 풍부하게 만들 수 있어요. 새 이벤트는 언제든 추가될 수 있으므로 trace/2 함수에 "catch-all" 절을 두는 것이 좋습니다.

모든 원격 함수 호출을 출력하는 트레이서 예시는 다음과 같아요:

defmodule MyTracer do
  def trace({:remote_function, _meta, module, name, arity}, env) do
    IO.puts("#{env.file}:#{env.line} #{inspect(module)}.#{name}/#{arity}")
    :ok
  end

  def trace(_event, _env) do
    :ok
  end
end

코드 경로 관리

Erlang VM이 모듈 코드를 찾는 디렉터리 목록을 코드 경로(code path)라고 해요. 이 목록은 Erlang VM 노드별로 관리됩니다. append_path/1은 경로를 목록에 추가하고, prepend_path/1은 맨 앞에 넣으며, delete_path/1은 제거해요. 경로는 추가·삭제 전에 Path.expand/1로 확장되고 존재해야 합니다. 결과로 성공 여부를 boolean으로 반환해요.

Code.append_path(".")
#=> true

Code.append_path("/does_not_exist")
#=> false

append_paths/1, prepend_paths/1, delete_paths/1(모두 v1.15.0부터)는 목록으로 여러 경로를 한 번에 다룹니다. :cache 옵션은 코드 경로를 처음 순회할 때 캐시해 파일 시스템 연산을 줄여줘요.

코드 평가

Code는 문자열이나 quoted 표현식을 즉석에서 평가하는 함수들을 제공해요. 대표적인 것이 eval_string/3eval_quoted/3입니다.

@spec eval_string(
  List.Chars.t(),
  binding(),
  Macro.Env.t() | [eval_opt() | env_eval_opt()]
) ::
  {term(), binding()}

eval_string/3은 문자열 내용을 평가해 {value, binding} 형태의 튜플을 반환해요. binding은 문자열을 평가한 후의 모든 변수 이름과 값의 목록이에요. 예를 들어:

iex> {result, binding} = Code.eval_string("a + b", [a: 1, b: 2], file: __ENV__.file, line: __ENV__.line)
iex> result
3
iex> Enum.sort(binding)
[a: 1, b: 2]

편의상 opts_or_env 인자로 __ENV__/0을 넘기면 현재 환경에 정의된 모든 import, require, alias가 자동으로 이어집니다:

iex> require Integer, warn: false
iex> {result, binding} = Code.eval_string("if Integer.is_odd(a), do: a + b", [a: 1, b: 2], __ENV__)
iex> result
3
iex> Enum.sort(binding)
[a: 1, b: 2]

eval_quoted/3은 quoted(인용된) AST를 평가하고, eval_quoted_with_env/4는 주어진 bindingenv로 평가합니다. 후자는 인터랙티브 셸처럼 여러 번 평가하는 기능을 구현하기 위해 루프에서 호출하도록 설계됐어요. 첫 호출 때는 env_for_eval/1로 초기 환경을 계산하고, 이후 호출에는 이 함수가 반환한 환경을 넘겨야 합니다.

경고: string은 어떤 Elixir 코드든 될 수 있고 Erlang VM과 같은 권한으로 실행됩니다. 즉 그런 코드는 (시스템 명령 실행 같은 것으로) 머신을 위협할 수 있어요. 신뢰할 수 없는 입력(네트워크에서 온 문자열 같은)에 eval_string/3을 사용하지 마세요.

매크로 안에서 eval_quoted/3을 호출하는 것은 나쁜 관행으로 간주됩니다. 런타임 값을 컴파일 타임에 평가하려 시도하기 때문이에요. 매크로 인자는 보통 (평가되는 대신) 반환된 quoted 표현식으로 unquote되어 변환됩니다.

코드 컴파일과 파싱

compile_string/2는 문자열을, compile_quoted/2는 quoted 표현식을 컴파일해 모듈 이름과 바이트코드(binary)의 튜플 목록을 반환합니다.

@spec compile_string(List.Chars.t(), binary()) :: [{module(), binary()}]

string_to_quoted/2는 문자열을 quoted 표현식(즉 AST)으로 변환합니다. :file, :line, :column, :columns, :token_metadata, :literal_encoder 등의 파싱 옵션을 t:parser_opts/0로 받아요. 실패 시 오류를 던지는 string_to_quoted!/2와, 주석을 보존하는 string_to_quoted_with_comments/2도 있습니다.

코드 포매팅

format_string!/2는 주어진 코드 문자열을 Elixir 스타일로 포매팅해요. format_file!/2는 파일을 포매팅합니다. t:format_opt/0 옵션에는 :file, :line, :line_length, :locals_without_parens, :force_do_end_blocks, 여러 :migrate_*(자동 마이그레이션) 옵션 등이 있어요. 내부적으로는 quoted_to_algebra/2가 AST를 폼atter의 대수(algebra) 문서로 변환합니다.

진단과 문서

컴파일러와 코드 평가가 반환하는 진단(diagnostic)은 t:diagnostic/0 타입으로 표현되며 source, file, severity, message, position, span 등의 필드를 담아요. print_diagnostic/1은 진단을 인쇄하고, with_diagnostics/1은 진단 컬렉터와 함께 실행됩니다. fetch_docs/1은 모듈의 문서를 {:docs_v1, ...} 형태로 가져옵니다.

컴파일러 옵션은 compiler_options/0, get_compiler_option/1, put_compiler_option/2, available_compiler_options/0로 관리할 수 있어요. 예를 들어:

Code.compiler_options()
#=> %{debug_info: true, docs: true, ...}

로딩 확인

ensure_loaded/1과 그 변형들은 모듈이 로드됐는지 확인합니다. ensure_all_loaded/1(v1.15.0부터)은 모듈 목록을 받아 전부 로드하고, 모두 성공하면 :ok을, 그렇지 않으면 오류 목록을 반환해요.

iex> Code.ensure_all_loaded([Atom, String])
:ok

iex> Code.ensure_all_loaded([Atom, DoesNotExist])
{:error, [{DoesNotExist, :nofile}]}

더 알아보기

  • Code 모듈의 완전한 함수 목록은 Elixir hexdocs에서 확인할 수 있어요.
  • AST 조작에 관심이 있다면 Macro 모듈을 참고하세요.
  • .beam 파일과 VM 수준의 코드 로딩은 Erlang의 :code 모듈 문서에서 다룹니다.
  • 병렬 컴파일은 Kernel.ParallelCompiler를 참고하세요.