변수의 스코프

변수의 스코프 (Scope of Variables)

변수의 스코프(scope) 란 변수에 접근할 수 있는 코드 영역을 말해요. 변수 스코프는 이름이 충돌하는 문제를 피하게 도와주죠. 이 개념은 본능적이에요. 두 함수가 둘 다 x라는 인자를 가질 수 있는데, 그 두 x가 같은 것을 가리키지는 않아요. 비슷하게, 서로 다른 코드 블록이 같은 이름을 쓰면서도 같은 것을 가리키지 않는 경우는 아주 많아요. 같은 변수 이름이 언제 같은 것을 가리키고 언제 다른 것을 가리키는지에 대한 규칙을 스코프 규칙(scope rules)이라고 하며, 이 절에서 자세히 다룰게요.

언어의 어떤 구문들은 스코프 블록(scope block) 을 도입해요. 스코프 블록은 특정 변수 집합의 스코프가 될 수 있는 코드 영역이에요. 변수의 스코프는 임의의 소스 줄 집합일 수 없고, 항상 이 블록들 중 하나와 정확히 맞닿아요. Julia에는 크게 두 종류의 스코프가 있어요. 전역 스코프(global scope)지역 스코프(local scope) 가 그것이에요. 지역 스코프는 중첩될 수 있어요. 또한 Julia에서는 "하드 스코프(hard scope)"를 도입하는 구문과 "소프트 스코프(soft scope)"만 도입하는 구문을 구분하는데, 이는 같은 이름의 전역 변수를 섀도잉(shadowing)해도 되는지 여부에 영향을 줘요.

요약: 전역 스코프에 정의된 변수는, 안전과 편의의 균형을 위해 코드가 실행되는 위치에 따라 내부 지역 스코프에서 미정의(undefined)일 수 있어요. 하드/소프트 지역 스코프 규칙이 전역 변수와 지역 변수의 상호작용을 정의해요.

단, 지역 스코프에만 정의된 변수는 모든 문맥에서 일관되게 동작해요. 변수가 이미 정의되어 있으면 재사용되고, 정의되어 있지 않으면 현재 스코프와 내부 스코프(바깥 스코프는 제외)에서 사용 가능해져요.

흔한 혼란: 예상치 못하게 변수가 정의되지 않았다는(undefined) 오류를 만난다면,

# Print the numbers 1 through 5
i = 0
while i < 5
    i += 1     # ERROR: UndefVarError: `i` not defined
    println(i)
end

간단한 해결책은 모든 전역 변수 정의를 let 블록이나 function으로 감싸 지역 정의로 바꾸는 거예요.

# Print the numbers 1 through 5
let i = 0
    while i < 5
        i += 1     # Now outer `i` is defined in the inner scope of the while loop
        println(i)
    end
end

이는 절차적 스크립트를 작성할 때 흔한 혼란의 원인이지만, 코드를 함수 안으로 옮기거나 REPL에서 대화형으로 실행하면 문제가 되지 않아요.

원하는 스코프 동작을 명시적으로 얻으려면 globallocal 키워드도 참고하세요.

출처: Julia 공식 매뉴얼 - Scope of Variables

스코프 구문 (Scope Constructs)

스코프 블록을 도입하는 구문은 다음과 같아요.

구문 도입되는 스코프 종류 이 구문을 포함할 수 있는 스코프 종류
module, baremodule global global
struct local (hard) global
macro local (hard) global
for, while, [try](@ref try) local (soft) global, local
function, do, let, 컴프리헨션(comprehensions), 제너레이터(generators) local (hard) global, local

이 표에서 눈에 띄게 빠진 것은 [begin 블록](@ref man-compound-expressions)과 [if 블록](@ref man-conditional-evaluation)인데, 이 둘은 새 스코프를 도입하지 않아요. 세 종류의 스코프는 각각 조금씩 다른 규칙을 따르며, 아래에서 설명할게요.

Julia는 어휘적 스코프(lexical scoping)를 사용해요. 즉 함수의 스코프는 호출자(caller)의 스코프로부터 상속되지 않고, 함수가 정의된 스코프로부터 상속돼요. 예를 들어 다음 코드에서 foo 안의 x는 모듈 Bar의 전역 스코프에 있는 x를 가리켜요.

julia> module Bar
           x = 1
           foo() = x
       end;

foo사용된 스코프의 x를 가리키는 게 아니에요.

julia> import .Bar

julia> x = -1;

julia> Bar.foo()
1

어휘적 스코프 란 특정 코드 조각 안의 변수가 무엇을 가리키는지가, 그 코드 자체만 보고도 추론될 수 있으며 프로그램이 어떻게 실행되는지에 의존하지 않는다는 뜻이에요. 다른 스코프 안에 중첩된 스코프는 자신을 포함하는 모든 바깥 스코프의 변수를 "볼 수" 있어요. 반대로 바깥 스코프는 안쪽 스코프의 변수를 볼 수 없어요.

전역 스코프 (Global Scope)

각 모듈은 다른 모든 모듈의 전역 스코프와 분리된 새 전역 스코프를 도입해요. 모든 것을 아우르는 전역 스코프는 존재하지 않아요. 모듈은 [using 또는 import](@ref modules) 문이나 점 표기법(dot-notation)을 통한 한정 접근(qualified access)으로 다른 모듈의 변수를 자기 스코프로 들여올 수 있어요. 즉 각 모듈은 이른바 네임스페이스(namespace) 이자, 이름과 값을 연결하는 일급(first-class) 데이터 구조이기도 해요.

최상위(top-level) 표현식에 local 키워드가 있는 변수 선언이 포함되어 있으면, 그 변수는 그 표현식 밖에서는 접근할 수 없어요. 표현식 안의 변수는 같은 이름의 전역 변수에 영향을 주지 않아요. 예를 들어 최상위의 begin 또는 if 블록에서 local x를 선언하는 경우가 있어요.

julia> x = 1
       begin
           local x = 0
           @show x
       end
       @show x;
x = 0
x = 1

대화형 프롬프트(즉 REPL)는 모듈 Main의 전역 스코프에 있다는 점을 참고하세요.

지역 스코프 (Local Scope)

대부분의 코드 블록은 새 지역 스코프를 도입해요(전체 목록은 위 [표](@ref man-scope-table) 참고). 그런 블록이 다른 지역 스코프 안에 문법적으로 중첩되어 있으면, 그것이 만드는 스코프는 자신이 나타나는 모든 지역 스코프 안에 중첩되며, 결국 모두 코드가 평가되는 모듈의 전역 스코프 안에 중첩돼요. 바깥 스코프의 변수는 자신이 포함한 모든 스코프에서 보여요 — 즉 안쪽 스코프에서 읽고 쓸 수 있어요 — 단, 같은 이름으로 바깥 변수를 "섀도잉"하는 변수가 없을 때 말이에요. 이는 바깥 지역(local)이 안쪽 블록보다 나중에(텍스트상 아래쪽에) 선언된 경우에도 마찬가지예요. 변수가 주어진 스코프에 "존재한다"고 말할 때는, 그 이름의 변수가 현재 스코프가 중첩된 모든 스코프(현재 스코프 포함) 중 어느 하나에 존재한다는 뜻이에요. 지역 스코프에서 변수의 값이 사용되는데 이 스코프에 그 이름이 존재하지 않는다면, 그 변수는 전역(global)으로 간주돼요.

어떤 프로그래밍 언어는 변수를 사용하기 전에 명시적으로 선언하도록 요구해요. 명시적 선언은 Julia에서도 동작해요. 어떤 지역 스코프에서든 local x라고 쓰면, 바깥 스코프에 이미 x라는 변수가 있든 없든 그 스코프에 새 지역 변수를 선언해요. 하지만 이렇게 모든 새 변수를 선언하는 건 다소 장황하고 지루해요. 그래서 Julia는 다른 많은 언어처럼, 아직 존재하지 않는 변수 이름에 대한 할당을 그 변수의 암묵적 선언으로 간주해요. 현재 스코프가 전역이면 새 변수는 전역이고, 현재 스코프가 지역이면 새 변수는 가장 안쪽 지역 스코프에 대한 지역 변수이며 그 스코프 안에서 보이지만 밖에서는 보이지 않아요. 기존 지역(local)에 할당하면 _항상_ 그 기존 지역을 갱신해요. 지역을 섀도잉할 수 있는 유일한 방법은 중첩 스코프에서 local 키워드로 새 지역을 명시적으로 선언하는 것뿐이에요. 특히 이 규칙은 내부 함수에서 할당된 변수에도 적용되는데, 내부 함수에서의 할당이 명시적으로 non-local로 선언되지 않는 한 새 지역을 만드는 Python에서 온 사용자에게는 놀라울 수 있어요.

대체로 이건 꽤 직관적이지만, 본능적으로 동작하는 많은 것들이 그렇듯 세부 사항은 단순히 상상하는 것보다 훨씬 미묘해요.

지역 스코프에서 x = <value>가 발생하면, Julia는 할당 표현식이 어디에 있고 그 위치에서 x가 이미 무엇을 가리키는지에 따라 그 표현식의 의미를 결정하기 위해 다음 규칙을 적용해요.

  1. 기존 지역(Existing local): x이미 지역 변수라면 기존 지역 x에 할당돼요.
  2. 하드 스코프(Hard scope): x아직 지역 변수가 아니고 할당이 어떤 하드 스코프 구문 안(즉 let 블록, 함수, struct 또는 macro 본문, 컴프리헨션, 제너레이터 안)에서 발생하면, 할당이 일어난 스코프에 x라는 새 지역이 생성돼요.
  3. 소프트 스코프(Soft scope): x아직 지역 변수가 아니고 할당을 포함한 모든 스코프 구문이 소프트 스코프(루프, try/catch 블록)라면, 그 동작은 전역 변수 x가 정의되어 있는지에 따라 달라져요.
    • 전역 x정의되지 않았다면, 할당이 일어난 스코프에 x라는 새 지역이 생성돼요.
    • 전역 x정의되어 있다면, 그 할당은 모호한(ambiguous) 것으로 간주돼요.
      • 비대화형 문맥(파일, eval)에서는 모호성 경고가 출력되고 새 지역이 생성돼요.
      • 대화형 문맥(REPL, 노트북)에서는 전역 변수 x에 할당돼요.

비대화형 문맥에서는 암묵적 지역 변수(즉 local x로 선언되지 않은)가 전역을 섀도잉할 때 경고가 출력된다는 점만 빼면 하드·소프트 스코프 동작이 동일하다는 점을 알 수 있어요. 대화형 문맥에서는 편의를 위해 더 복잡한 휴리스틱을 따르죠. 이 내용은 다음 예시들에서 깊이 다룰게요.

이제 규칙을 알았으니 예시를 살펴볼게요. 각 예시는 새 REPL 세션에서 평가된다고 가정해요. 따라서 각 스니펫의 유일한 전역은 그 코드 블록에서 할당된 것들뿐이에요.

먼저 아주 명쾌한 상황부터 시작할게요 — 같은 이름의 지역 변수가 이미 존재하지 않을 때, 하드 스코프(여기서는 함수 본문) 안에서의 할당이에요.

julia> function greet()
           x = "hello" # new local
           println(x)
       end
greet (generic function with 1 method)

julia> greet()
hello

julia> x # global
ERROR: UndefVarError: `x` not defined in `Main`

greet 함수 안에서 할당 x = "hello"x가 함수 스코프의 새 지역 변수가 되게 해요. 관련된 두 가지 사실이 있어요. 할당이 지역 스코프에서 발생했고, 기존 지역 x 변수가 없다는 것이에요. x가 지역이므로 x라는 전역이 있는지 없는지는 중요하지 않아요. 여기서는 greet를 정의하고 호출하기 전에 x = 123을 정의해 볼게요.

julia> x = 123 # global
123

julia> function greet()
           x = "hello" # new local
           println(x)
       end
greet (generic function with 1 method)

julia> greet()
hello

julia> x # global
123

greet 안의 x는 지역이므로, greet 호출이 전역 x의 값(혹은 부재)에 영향을 미치지 않아요. 하드 스코프 규칙은 x라는 전역이 존재하는지 여부를 신경 쓰지 않아요. 하드 스코프에서 x에 대한 할당은 (x가 global로 선언되지 않는 한) 지역이에요.

다음으로 살펴볼 명쾌한 상황은 이미 x라는 지역 변수가 있을 때예요. 이 경우 x = <value>는 항상 이 기존 지역 x에 할당해요. 이는 할당이 같은 지역 스코프에서 일어나든, 같은 함수 본문의 안쪽 지역 스코프에서 일어나든, 다른 함수 안에 중첩된 함수의 본문(즉 클로저(closure))에서 일어나든 마찬가지예요.

예시로, 1부터 n까지 정수의 합을 계산하는 sum_to 함수를 쓸게요.

function sum_to(n)
    s = 0 # new local
    for i = 1:n
        s = s + i # assign existing local
    end
    return s # same local
end

이전 예시처럼, sum_to의 맨 위에서 s에 대한 첫 할당은 s가 함수 본문의 새 지역 변수가 되게 해요. for 루프는 함수 스코프 안에 자체적인 내부 지역 스코프를 가져요. s = s + i가 발생하는 지점에서 s는 이미 지역 변수이므로, 그 할당은 새 지역을 만들지 않고 기존 s를 갱신해요. REPL에서 sum_to를 호출해 확인해 볼게요.

julia> function sum_to(n)
           s = 0 # new local
           for i = 1:n
               s = s + i # assign existing local
           end
           return s # same local
       end
sum_to (generic function with 1 method)

julia> sum_to(10)
55

julia> s # global
ERROR: UndefVarError: `s` not defined in `Main`

s는 함수 sum_to에 대한 지역이므로, 함수 호출은 전역 변수 s에 아무 영향을 주지 않아요. 또한 for 루프 안의 갱신 s = s + i가 반드시 초기화 s = 0이 만든 같은 s를 갱신했다는 점도 알 수 있어요. 1부터 10까지 정수의 합이 올바르게 55로 나오기 때문이죠.

for 루프 본문이 자체 스코프를 가진다는 사실을 좀 더 파고들기 위해, sum_to_def라는 조금 더 장황한 변형을 작성해 볼게요. 여기서는 s를 갱신하기 전에 합 s + i를 변수 t에 저장해요.

julia> function sum_to_def(n)
           s = 0 # new local
           for i = 1:n
               t = s + i # new local `t`
               s = t # assign existing local `s`
           end
           return s, @isdefined(t)
       end
sum_to_def (generic function with 1 method)

julia> sum_to_def(10)
(55, false)

이 버전은 이전처럼 s를 반환하지만, @isdefined 매크로로 함수의 가장 바깥 지역 스코프에 t라는 지역 변수가 정의되어 있는지 나타내는 불리언도 함께 반환해요. 보시다시피 for 루프 본문 밖에는 t가 정의되어 있지 않아요. 이것도 하드 스코프 규칙 때문이에요. 함수 안(하드 스코프를 도입하는)에서 t에 대한 할당이 발생하므로, 그 할당은 t가 나타나는 지역 스코프(즉 루프 본문 안)에서 새 지역 변수가 되게 해요. t라는 전역이 있더라도 차이는 없어요 — 하드 스코프 규칙은 전역 스코프의 어떤 것에도 영향을 받지 않으니까요.

for 루프 본문의 지역 스코프는 내부 함수의 지역 스코프와 다르지 않다는 점을 주목하세요. 즉 이 예시를 루프 본문을 내부 헬퍼 함수 호출로 구현하도록 다시 써도 동일하게 동작해요.

julia> function sum_to_def_closure(n)
           function loop_body(i)
               t = s + i # new local `t`
               s = t # assign same local `s` as below
           end
           s = 0 # new local
           for i = 1:n
               loop_body(i)
           end
           return s, @isdefined(t)
       end
sum_to_def_closure (generic function with 1 method)

julia> sum_to_def_closure(10)
(55, false)

이 예시는 몇 가지 핵심 사항을 보여줘요.

  1. 내부 함수 스코프는 다른 중첩 지역 스코프와 똑같아요. 특히, 어떤 변수가 내부 함수 밖에서 이미 지역(local)이고 내부 함수에서 그 변수에 할당한다면, 그 바깥 지역 변수가 갱신돼요.
  2. 바깥 지역의 정의가 갱신되는 지점보다 아래에 있든 없든 규칙은 동일해요. 내부 지역의 의미가 해석되기 전에 둘러싼 전체 지역 스코프가 파싱되고 그 지역들이 결정돼요.

이 설계 덕분에 일반적으로 코드를 내부 함수 안으로 또는 밖으로 옮겨도 의미가 바뀌지 않아요. 이는 언어에서 클로저를 쓰는 여러 흔한 관용구(자세한 내용은 [do 블록](@ref Do-Block-Syntax-for-Function-Arguments) 참고)를 용이하게 해 줘요.

이제 소프트 스코프 규칙이 적용되는 더 모호한 경우로 넘어가 볼게요. greetsum_to_def 함수의 본문을 소프트 스코프 문맥으로 끄집어내서 탐구할 거예요. 먼저 greet의 본문을 for 루프(하드가 아니라 소프트인)에 넣고 REPL에서 평가해 볼게요.

julia> for i = 1:3
           x = "hello" # new local
           println(x)
       end
hello
hello
hello

julia> x
ERROR: UndefVarError: `x` not defined in `Main`

for 루프가 평가될 때 전역 x가 정의되어 있지 않으므로 소프트 스코프 규칙의 첫 번째 조항이 적용되어 xfor 루프에 지역으로 생성돼요. 따라서 루프가 실행된 후에도 전역 x는 정의되지 않은 채로 남아요. 다음으로 sum_to_def의 본문을 전역 스코프로 끄집어내고 인자를 n = 10으로 고정해 볼게요.

s = 0
for i = 1:10
    t = s + i
    s = t
end
s
@isdefined(t)

이 코드는 무엇을 할까요? 힌트: 함정 문제예요. 정답은 "경우에 따라 다르다"예요. 이 코드를 대화형으로 입력하면 함수 본문에서와 동일하게 동작해요. 하지만 코드가 파일에 있으면 모호성 경고를 출력하고 정의되지 않은 변수 오류를 던져요. 먼저 REPL에서 동작하는 걸 볼게요.

julia> s = 0 # global
0

julia> for i = 1:10
           t = s + i # new local `t`
           s = t # assign global `s`
       end

julia> s # global
55

julia> @isdefined(t) # global
false

REPL은 그 이름의 전역 변수가 정의되었는지 여부에 따라 루프 안의 할당이 전역에 할당하는지 새 지역을 만드는지 결정함으로써, 함수 본문 안에 있는 것처럼 동작하도록 근사해요. 그 이름의 전역이 존재하면 할당이 그것을 갱신하고, 전역이 없으면 할당이 새 지역 변수를 만들어요. 이 예시에서 두 경우를 모두 볼 수 있어요.

  • t라는 전역이 없으므로 t = s + ifor 루프에 지역인 새 t를 만들어요.
  • s라는 전역이 있으므로 s = t는 그것에 할당해요.

두 번째 사실이 루프 실행이 전역 s의 값을 바꾸는 이유이고, 첫 번째 사실이 루프가 실행된 후에도 t가 여전히 미정의인 이유예요. 이제 이 같은 코드를 파일 안에 있는 것처럼 평가해 볼게요.

julia> code = """
       s = 0 # global
       for i = 1:10
           t = s + i # new local `t`
           s = t # new local `s` with warning
       end
       s, # global
       @isdefined(t) # global
       """;

julia> include_string(Main, code)
┌ Warning: Assignment to `s` in soft scope is ambiguous because a global variable by the same name exists: `s` will be treated as a new local. Disambiguate by using `local s` to suppress this warning or `global s` to assign to the existing global variable.
└ @ string:4
ERROR: LoadError: UndefVarError: `s` not defined in local scope

여기서는 include_string을 사용해 code를 파일의 내용인 것처럼 평가해요. code를 파일로 저장한 뒤 그 파일에 include를 호출해도 되고, 결과는 같아요. 보시다시피 REPL에서 같은 코드를 평가하는 것과는 아주 다르게 동작해요. 여기서 무슨 일이 일어나는지 하나씩 풀어볼게요.

  • 전역 s는 루프가 평가되기 전에 값 0으로 정의돼요.
  • 할당 s = t는 소프트 스코프 — 어떤 함수 본문이나 다른 하드 스코프 구문 밖의 for 루프 — 에서 발생해요.
  • 따라서 소프트 스코프 규칙의 두 번째 조항이 적용되고, 할당이 모호해져 경고가 출력돼요.
  • 실행은 계속되어 sfor 루프 본문에 지역으로 만들어요.
  • sfor 루프에 지역이므로, t = s + i가 평가될 때 s가 미정의되어 오류가 발생해요.
  • 평가는 거기서 멈추지만, s@isdefined(t)까지 갔다면 0false를 반환했을 거예요.

이 예시는 스코프의 중요한 측면을 보여줘요. 한 스코프에서 각 변수는 단 하나의 의미만 가질 수 있고, 그 의미는 표현식의 순서와 무관하게 결정돼요. 루프 안에 s = t라는 표현식이 있기 때문에 s는 루프에 지역이 되고, 그 결과 t = s + i의 오른쪽에 나타날 때도 지역이 돼요. 비록 그 표현식이 먼저 나타나고 먼저 평가되더라도 말이에요. 루프의 첫 줄의 s는 전역이고 두 번째 줄의 s는 지역일 거라고 상상할 수도 있는데, 그건 불가능해요. 두 줄이 같은 스코프 블록 안에 있고 각 변수는 주어진 스코프에서 단 하나의 의미만 가질 수 있으니까요.

소프트 스코프에 관해 (On Soft Scope)

이제 모든 지역 스코프 규칙을 다뤘어요. 하지만 이 절을 마무리하기 전에, 모호한 소프트 스코프 경우가 왜 대화형 문맥과 비대화형 문맥에서 다르게 처리되는지에 대해 몇 마디 해 볼게요. 물어볼 수 있는 두 가지 분명한 질문이 있어요.

  1. 왜 모든 곳에서 REPL처럼 동작하지 않는 걸까?
  2. 왜 모든 곳에서 파일처럼 동작하지 않을까? 그리고 경고를 빼면 안 될까?

Julia ≤ 0.6에서는 모든 전역 스코프가 현재 REPL처럼 동작했어요. x = <value>가 함수 본문(또는 let 블록이나 컴프리헨션) 밖의 루프(또는 try/catch, struct 본문)에서 발생하면, x라는 전역이 정의되어 있는지 여부에 따라 x를 루프에 지역으로 할지가 결정됐어요. 이 동작은 함수 본문 안의 동작을 가능한 한 가깝게 근사하므로 직관적이고 편리하다는 장점이 있어요. 특히 함수의 동작을 디버깅할 때 코드를 함수 본문과 REPL 사이로 쉽게 옮길 수 있게 해 줘요. 하지만 단점도 있어요. 첫째, 꽤 복잡한 동작이에요. 수년 동안 많은 사람들이 이 동작에 혼란을 겪었고, 설명하고 이해하기 어렵고 복잡하다고 불평했어요. 일리가 있는 지적이에요. 둘째, 무엇보다 "규모가 큰 프로그래밍(at scale)"에 나빠요. 이런 작은 코드 조각을 한 곳에서 보면 무슨 일이 일어나는지 아주 명확해요.

s = 0
for i = 1:10
    s += i
end

분명 의도는 기존 전역 변수 s를 수정하는 거예요. 다른 의미가 있을 수 있겠어요? 하지만 모든 실제 코드가 이렇게 짧고 명확한 건 아니에요. 실제로 다음 같은 코드가 흔히 나타나는 걸 발견했어요.

x = 123

# much later
# maybe in a different file

for i = 1:10
    x = "hello"
    println(x)
end

# much later
# maybe in yet another file
# or maybe back in the first one where `x = 123`

y = x + 234

여기서 무엇이 일어나야 하는지는 훨씬 덜 명확해요. x + "hello"는 메서드 오류이므로, 의도는 xfor 루프에 지역이라는 것일 가능성이 커요. 하지만 런타임 값과 우연히 존재하는 메서드로는 변수의 스코프를 결정할 수 없어요. Julia ≤ 0.6 동작에서는 누군가 먼저 for 루프를 작성해서 잘 동작하게 만들었는데, 나중에 다른 사람이 멀리 떨어진 곳 — 어쩌면 다른 파일 — 에 새 전역을 추가하면 코드가 갑자기 의미가 바뀌어 시끄럽게 깨지거나, 더 나쁘게는 조용히 잘못된 일을 하는 상황이 특히 우려돼요. 이런 종류의 "스푸키한 원격 작용(spooky action at a distance)"은 훌륭한 프로그래밍 언어 설계가 막아야 하는 것이에요.

그래서 Julia 1.0에서 우리는 스코프 규칙을 단순화했어요. 어떤 지역 스코프에서든 이미 지역 변수가 아닌 이름에 대한 할당은 새 지역 변수를 만들었어요. 이것은 소프트 스코프라는 개념 자체를 없애고 원격 작용의 가능성도 제거했어요. 그리고 소프트 스코프 제거 덕분에 상당한 수의 버그를 발견·수정했는데, 이는 소프트 스코프를 없앤 선택이 옳았음을 뒷받침해요. 그리고 큰 환호가 있었어요! 뭐, 사실 그렇진 않아요. 어떤 사람들은 이제 이렇게 써야 한다는 사실에 화가 났거든요.

s = 0
for i = 1:10
    global s += i
end

저기 global 표기가 보이나요? 흉측하죠. 분명 이런 상황은 용납될 수 없었어요. 하지만 진지하게, 이런 최상위 코드에 global이 필요한 데는 두 가지 주요 문제가 있어요.

  1. 함수 본문 안의 코드를 REPL에 복사해서 디버깅하는 게 더 이상 편하지 않아요 — global 표기를 추가했다가 다시 원래대로 돌아가려면 또 제거해야 하거든요.
  2. 초보자가 global 없이 이런 코드를 작성하고 왜 코드가 동작하지 않는지 모를 거예요 — 받는 오류는 s가 미정의라는 것인데, 이는 이런 실수를 하는 누구에게도 도움이 되지 않는 것 같아요.

Julia 1.5부터 이 코드는 REPL이나 Jupyter 노트북 같은 대화형 문맥에서는 global 표기 없이 동작하고(Julia 0.6처럼요), 파일과 그 밖의 비대화형 문맥에서는 다음과 같은 매우 직접적인 경고를 출력해요.

Assignment to s in soft scope is ambiguous because a global variable by the same name exists: s will be treated as a new local. Disambiguate by using local s to suppress this warning or global s to assign to the existing global variable.

이것은 1.0 동작의 "규모 있는 프로그래밍" 이점을 유지하면서 두 문제를 모두 해결해요. 전역 변수는 멀리 떨어질 수 있는 코드의 의미에 스푸키한 영향을 주지 않고, REPL 복사-붙여넣기 디버깅은 동작하며 초보자도 문제가 없어요. 누군가 global 표기를 잊거나, 어쨌든 혼란스러울 소프트 스코프에서 지역으로 기존 전역을 실수로 섀도잉할 때마다 명확한 경고를 받게 돼요.

이 설계의 중요한 속성은, 파일 안에서 경고 없이 실행되는 어떤 코드든 새 REPL에서도 동일하게 동작한다는 거예요. 반대로, REPL 세션을 파일로 저장했을 때 REPL에서와 다르게 동작한다면, 경고를 받게 돼요.

let 블록 (Let Blocks)

let 문은 새 하드 스코프 블록을 만들고(위 참고) 실행될 때마다 새 변수 바인딩을 도입해요. 변수에 즉시 할당할 필요는 없어요.

julia> var1 = let x
           for i in 1:5
               (i == 4) && (x = i; break)
           end
           x
       end
4

할당이 기존 값 위치에 새 값을 재할당할 수 있는 반면, let은 항상 새 위치를 만들어요. 이 차이는 대개 중요하지 않고, 클로저를 통해 스코프를 벗어나 오래 사는 변수의 경우에만 감지할 수 있어요. let 구문은 쉼표로 구분된 일련의 할당과 변수 이름을 받아요.

julia> x, y, z = -1, -1, -1;

julia> let x = 1, z
           println("x: $x, y: $y") # x is local variable, y the global
           println("z: $z") # errors as z has not been assigned yet but is local
       end
x: 1, y: -1
ERROR: UndefVarError: `z` not defined in local scope

할당은 순서대로 평가되며, 각 오른쪽은 왼쪽의 새 변수가 도입되기 전에 그 스코프에서 평가돼요. 따라서 let x = x 같은 것을 쓰는 게 말이 되는데, 두 x 변수는 서로 구별되고 저장 공간도 따로 있기 때문이에요. let의 동작이 필요한 예시는 다음과 같아요.

julia> Fs = Vector{Any}(undef, 2); i = 1;

julia> while i <= 2
           Fs[i] = ()->i
           global i += 1
       end

julia> Fs[1]()
3

julia> Fs[2]()
3

여기서 변수 i를 반환하는 두 클로저를 만들어 저장해요. 하지만 항상 같은 변수 i라서 두 클로저는 동일하게 동작해요. let을 써서 i에 새 바인딩을 만들 수 있어요.

julia> Fs = Vector{Any}(undef, 2); i = 1;

julia> while i <= 2
           let i = i
               Fs[i] = ()->i
           end
           global i += 1
       end

julia> Fs[1]()
1

julia> Fs[2]()
2

begin 구문은 새 스코프를 도입하지 않으므로, 인자 없는 let을 사용해 즉시 새 바인딩을 만들지 않고 새 스코프 블록만 도입하는 것이 유용할 수 있어요.

julia> let
           local x = 1
           let
               local x = 2
           end
           x
       end
1

let이 새 스코프 블록을 도입하므로, 안쪽 지역 x는 바깥 지역 x와 다른 변수예요. 이 특정 예시는 다음 코드와 동등해요.

julia> let x = 1
           let x = 2
           end
           x
       end
1

루프와 컴프리헨션 (Loops and Comprehensions)

루프와 [컴프리헨션](@ref man-comprehensions)에서 본문 스코프에 도입된 새 변수는 마치 루프 본문이 let 블록으로 둘러싸인 것처럼 각 루프 반복마다 새로 할당돼요. 다음 예시가 그걸 보여줘요.

julia> Fs = Vector{Any}(undef, 2);

julia> for j = 1:2
           Fs[j] = ()->j
       end

julia> Fs[1]()
1

julia> Fs[2]()
2

for 루프나 컴프리헨션의 반복 변수는 항상 새 변수예요.

julia> function f()
           i = 0
           for i = 1:3
               # empty
           end
           return i
       end;

julia> f()
0

하지만 기존 지역 변수를 반복 변수로 재사용하는 게 가끔은 유용해요. 키워드 outer를 추가하면 편리하게 그렇게 할 수 있어요.

julia> function f()
           i = 0
           for outer i = 1:3
               # empty
           end
           return i
       end;

julia> f()
3

상수 (Constants)

변수의 흔한 용도는 특정하고 변하지 않는 값에 이름을 붙이는 거예요. 그런 변수는 한 번만 할당돼요. 이런 의도를 const 키워드로 컴파일러에 전달할 수 있어요.

julia> const e  = 2.71828182845904523536;

julia> const pi = 3.14159265358979323846;

단일 const 문에서 여러 변수를 선언할 수도 있어요.

julia> const a, b = 1, 2
(1, 2)

const 선언은 전역 스코프에서 전역 변수에만 사용해야 해요. 전역 변수의 값(심지어 타입)이 거의 언제든 바뀔 수 있으므로, 컴파일러가 전역 변수를 포함하는 코드를 최적화하기 어려워요. 전역 변수가 바뀌지 않을 거라면 const 선언을 추가하면 이 성능 문제가 해결돼요.

지역 상수(local constant)는 상당히 다르게 동작해요. 컴파일러는 지역 변수가 상수인지 자동으로 판단할 수 있으므로 지역 상수 선언은 필요하지 않고, 사실 현재는 지원되지도 않아요.

functionstruct 키워드가 수행하는 것 같은 특별한 최상위 할당은 기본적으로 상수예요.

const는 변수 바인딩에만 영향을 준다는 점을 참고하세요. 그 변수가 배열 같은 가변 객체에 묶여 있을 수 있고, 그 객체는 여전히 수정될 수 있어요. 추가로, 상수로 선언된 변수에 값을 할당하려 할 때 다음 시나리오가 가능해요.

  • const 키워드 없이 상수를 교체하려는 시도는 금지돼요.

    julia> const x = 1.0
    1.0
    
    julia> x = 1
    ERROR: invalid assignment to constant x. This redefinition may be permitted using the `const` keyword.
    
  • 그 밖의 상수 정의는 모두 허용되지만 상당한 재컴파일을 유발할 수 있어요.

    julia> const y = 1.0
    1.0
    
    julia> const y = 2.0
    2.0
    

compat — Julia 1.12: Julia 1.12 이전에는 상수 재정의가 제대로 지원되지 않았어요. 같은 타입의 상수 재정의로 제한되었고, 관찰 가능한 잘못된 동작이나 크래시를 유발할 수 있었어요. 상수 재정의는 1.12 이전 버전에서는 매우 권장되지 않아요. 더 자세한 내용은 이전 Julia 버전의 매뉴얼을 참고하세요.

타입이 지정된 전역 변수 (Typed Globals)

compat — Julia 1.8: 타입이 지정된 전역 변수 지원은 Julia 1.8에서 추가됐어요.

상수로 선언되는 것과 유사하게, 전역 바인딩이 항상 일정한 타입을 갖도록 선언할 수도 있어요. 이는 실제 값 없이 global x::T 구문으로 하거나, 할당 시 x::T = 123으로 할 수 있어요.

julia> x::Float64 = 2.718
2.718

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

julia> Base.return_types(f)
1-element Vector{Any}:
 Float64

전역에 대한 어떤 할당이든 Julia는 먼저 convert를 사용해 적절한 타입으로 변환하려고 해요.

julia> global y::Int

julia> y = 1.0
1.0

julia> y
1

julia> y = 3.14
ERROR: InexactError: Int64(3.14)
Stacktrace:
[...]

타입은 구체(concrete)일 필요는 없지만, 추상 타입으로 주석을 달아도 보통 성능상 이점이 거의 없어요.

전역에 값이 할당되거나 타입이 설정된 후에는 그 바인딩의 타입을 바꿀 수 없어요.

julia> x = 1
1

julia> global x::Int
ERROR: cannot set type for global x. It already has a value or is already set to a different type.
Stacktrace:
[...]

더 알아보기 (Learn more)

  • 변수 (Variables) — 변수 이름 짓기와 할당 표현식
  • 모듈 — 전역 스코프와 네임스페이스
  • 함수 — 지역 스코프와 클로저