프로그래밍 언어/Golang

Go: 변수 스코프와 블록

렉시컬 블록

렉시컬 블록은 명시적으로 선언되는 블록 뿐만 아니라, if, for, switch, select, case 에서 사용하는 블록과 중괄호로 묶이지 않는 선언의 그룹 및 광역, 패키지, 파일 블록을 모두 포함하는 개념이다.

 

렉시컬 블록은 블록의 범위를 결정하는데, 이는 스코프라고 부를 수 있다. Go 의 변수는 기본적으로 블록 스코프를 가지기 때문에 블록 외부에서 내부에 선언된 변수에 대해서는 접근하는 것이 불가능하다. 물론 변수의 스코프는 어디에 선언이 되어있는가에 따라 다르다. 함수나 패키지에 선언되어 있을 수도 있어서 함수 스코프가 되기도 하고 패키지 스코프가 되기도 한다. Go 에서의 가장 작은 스코프의 범위가 블록이라는 점은 중요 포인트다.

func main() {
	x := "Hello, Go!"
	for _, x := range x {
		fmt.Printf("%c", x)
	}
}

위의 코드에서 range xx 와 블록 내부에서 접근하는 x 는 서로 다른 것을 의미한다. 이는 Go 의 변수가 블록 스코프를 가지고 있기 때문이다. range xxHello, Go! 문자열을 의미하고, for 구문에 있는 x 변수는 for 블록 내부에서만 유효하며 Hello, Go! 의 각 문자 하나를 의미한다. 이는 명시적으로 중괄호로 묶이지는 않았지만, 하나의 렉시컬 블록으로 취급한다.

 

if 와 같이 명시적으로 중괄호로 묶이지 않은 것들에 대해서도 다음과 같이 접근할 수 있다. 그렇지만, 밖에서 접근하는 것은 허용하지 않는다.

if x := "Hello, Go"; x != "" {
	fmt.Println(x)
} else if y := x; x == y {
	fmt.Println(y)
} else {
	fmt.Println(x, y)
}
fmt.Println(x, y) // ERROR

함수, 패키지 스코프

변수가 작은 블록을 넘어 함수나 패키지 단위에 선언되어 있다면 내부 블록에서 외부 블록에 있는 변수에 접근할 수 있다. 다른 스코프에 같은 이름의 변수가 선언되어 있다면 가까운 범위에서 선언된 변수를 우선적으로 찾으며 그렇지 않다면 상위 스코프로 검색을 이어나가게 된다. 이러한 절차를 스코프 체이닝(Scope Chaining)이라고 한다.

func main() {
	x := "Hello, Go!"
	for _, x := range x {
		fmt.Printf("%c", x)
	}
}

위의 코드에서 main() 함수 아래에 선언된 x 는 함수 스코프이고, for 구문의 일부로 선언된 x 는 블록 스코프이다. 따라서 for 블록에서 x 변수에 접근할 때 가까운 스코프를 먼저 탐색하게 되므로 블록 스코프에 선언된 x 를 찾는 것이다. 

 

여기서 살펴볼만한 것은 짧은 변수선언 표현을 사용할 때 눈속임이 발생할 수 있다는 것이다. 아래의 코드를 보자.

var cwd string

func main() {
	cwd, err := os.Getwd()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(cwd)
}

이 코드의 cwd 변수는 전역 변수의 값을 바꾸는 것이 아니라 지역 변수로써 main() 함수 스코프에 선언하게 된다. 만약에 이것을 전역변수에 할당되는 것을 의도로한 것이라면 이 코드는 잘못된 것이므로 아래와 같이 바꿔줄 필요가 있다.

var cwd string

func main() {
	var err error
	cwd, err = os.Getwd()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(cwd)
}

아래의 코드와 같이 Go 에서는 두 개 이상의 값을 반환하는 함수를 호출하고 값을 할당할 때 이미 선언한 변수를 짧은 선언문에서 함께 사용하더라도 할당으로 취급하는 특징이 있는데, 스코프가 다른 경우 이는 해당되지 않는다는 점을 확인할 수 있다. 

func main() {
	f, err := os.Open("hello.txt")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	var buf bytes.Buffer
    
	n, err := buf.ReadFrom(f)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%#v, Read %d bytes", buf.String(), n)
}

err 변수가 두 번 선언되어 있는 모습을 볼 수 있는데, Go 에서는 둘 이상의 선언에서 하나 이상의 새로운 변수가 포함되어 있다면 같은 이름에 대해 선언이 아닌 할당으로 취급되어 n, err := buf.ReadFrom(f)err 변수는 할당으로 취급된다. 그러나 스코프가 다르다면 이는 선언이 된다.

렉시컬 스코프

Go 는 다른 언어와 마찬가지로 렉시컬 스코프를 가진다. 즉, 함수의 호출이 아닌 선언을 할 때 스코프가 결정되어 정적 스코프라고도 한다. 이것은 런타임 중에 동적으로 스코프가 일어나는 동적 스코프와는 반대되는 개념이다.

var x string = "Hello, Go!"

func foo() {
	fmt.Println(x)
}

func bar() string {
	x := "Goodbye"
	foo()

	return x
}

func main() {
	x := bar() // -> Hello, Go
	fmt.Println(x) // -> Goodbye
}

bar() 함수를 호출했을 때 출력되는 결과는 Hello, Go! 다. foo() 함수를 선언한 시점에서 스코프가 결정되어 전역변수 x 값을 기준으로 하기 때문이다. 그러나 bar() 함수가 반환하는 값은 지역변수 x 이므로 Goodbye 가 된다.