Learning Go 정리 3일차 - 블럭, 쉐도우 ,기본 구조

0

Learning Go

목록 보기
3/12

블록, 쉐도우, 제어 구조

블록

선언문이 있는 각각의 공간을 block이라고 부른다. 함수 외부에서 선언된 변수, 상수, 타입, 함수는 package내에 속한다. package내에 함수의 맨 상위에 있는 모든 선언문들, 변수들은 file블록 내에 있게 된다.

함수 내에서 중괄호({}) 세트는 다른 블록을 정의하며 go의 제어 구조가 자체 블록을 정의한다는 것을 볼 수 있다.

어떤 내부 블록에서 어떤 외부 블록에 선언된 식별자를 접근할 수 있다. 그렇다면 포함된 블록 사이에서 같은 이름을 가지는 식별자를 선언했다면 무슨 일이 발생할 지 의문이 생길 것이다. 만약, 이렇게된다면 외부 블록에 생성된 식별자를 내부에서 쉐도잉(shadowing)한다고 한다.

쉐도잉 변수

다음의 코드를 보도록 하자.

func main() {
	x := 10
	if x > 5 {
		fmt.Println(x)
		x := 5
		fmt.Println(x)
	}
	fmt.Println(x)
}

결과는 다음과 같다.

10
5
10

쉐도잉 변수는 포함된 블록 내에 이름이 같은 변수가 있는 것을 의미한다. 쉐도우 변수가 존재하는 한 쉐도잉 대상이 된 변수는 접근할 수 없다.

즉, if문 내에 main함수 블록에 있는 x변수와 이름이 같은 x라는 변수를 새롭게 선언함으로써 쉐도잉을 하게 된 것이다. 이에 따라 if문의 두 번째 fmt.Println문은 5가 출력된 것이다. if문이 종료됨에 따라 if문의 블럭이 종료되고 선언된 변수인 x가 해제된다. 단, main함수 블럭에 선언된 변수 x는 남아있기 떄문에 이를 출력하는 것이다.

이런 일이 발생하는 이유는 := 연산자를 사용하는 것이 정확히 어떤 변수를 대상으로 일어나는 지 불명확하기 때문에 발생한 일이다. :=를 사용할 떄, 우연히 쉐도잉된 변수일 수 있기 때문이다. :=연산자를 이용해 여러 개의 변수를 한번에 생성하고 값을 할당할 수 있다는 것을 기억하자.

또한, :=왼쪽에 모든 변수들이 새롭게 선언된 변수가 아니어도 사용이 가능하다. 즉, 왼쪽에 적어도 한 개의 변수만 새롭게 선언되더라도 :=을 사용 가능하다.

다음의 예시를 보도록 하자.

func main() {
	x := 10
	if x > 5 {
		x, y := 5, 10
		fmt.Println(x, y) // 5 10
	}
	fmt.Println(x) // 10
}

if문 외부 블럭에 이미 x변수가 선언되었다. 그리고 if문에 같은 이름인 x변수를 :=를 사용했으니, 쉐도잉 변수 x를 갖게된다. 별 문제 없어보이지만, 만약, if문안에 main에 있는 외부 변수 x의 값을 설정해주는 로직을 넣었다면 이는 문제가 있다. 왜냐하면 쉐도잉 된 if문 안의 x변수에 의해 if문 밖의 x에 접근할 수가 없기 때문이다.

사실 원래의 의도는 if문 안에 y라는 변수를 사용하기위해 :=을 사용하려고 했다. 그러나 :=은 새로운 변수를 선언하고 값을 할당하는 초기화를 하기 떄문에 해당 if문 블럭에는 x가 없어 같은 이름이지만 if문에만 유효한 새로운 변수 x를 새로 만들고 값을 대입한 것이다. 이런 실수를 별로 안할 것 같지만 다음의 코드를 보자.

func processing() (int, error) {
	return 2, nil
}

func main() {
	var x int
	if x == 0 {
		x, err := processing()
		if err != nil {
			fmt.Println(x, err)
		}
	}
	fmt.Println(x)
}

위의 코드는 변수 x가 zero value일 때 processing함수를 사용하여 값을 채우는 것이다. processing함수는 int, error를 반환하기 때문에 error를 받는 변수를 따로 만들어주어야 한다. 그래서 :=를 사용했더니 x가 쉐도잉이 되어버린다. 즉 if안의 같은 이름이지만 새로운 변수 x가 생겨버린 것이다. 이 값에는 2가 들어가지만 외부의 x는 가려져서 값이 채워지지 않는다.

0

따라서 다음과 같이 0이라는 결과를 얻게 된다. 이처럼 err를 처리하기위해 무의식적으로 :=을 사용하면 이전 변수를 쉐도잉하는 문제를 겪을 수 있다. 해결방법은 매우 간단하다. err변수를 따로 선언하거나 processing의 반환값을 임시 변수에 받아서 외부의 x에 할당해주면 된다.

func processing() (int, error) {
	return 2, nil
}

func main() {
	var x int
	if x == 0 {
		temp, err := processing()
		if err != nil {
			fmt.Println(x, err)
		}
		x = temp
	}
	fmt.Println(x)
}

결과는 5가 나올 것이다.

쉐도잉을 조심해야하는 것은 패키지 블럭에서도 마찬가지이다. 만약 패키지 이름을 쉐도잉하면 원치 않은 결과를 볼 수 있을 것이다.

func main() {
	x := 10
	fmt.Println(x)
	fmt := "oops"
	fmt.Println(fmt) // error
}

fmt패키지를 쉐도잉해버리면 해당 블럭에서 fmt패키지에 접근할 수가 없게된다.

만약, 이미 만든 프로그램에서 쉐도잉이 무섭다면 shadow툴이 있으니 다운받아서 사용하길 바란다.

go의 내장 타입, 상수, 함수, nil은 유니버스 블록이라는 곳에 미리 선언된 식별자(predeclared identifier)에 불과하다. 이 유니버스 블록은 모든 파일 블록에서 접근이 가능하다. 때문에 유니버스 블록에 정의된 어떤 식별자든 재정의되지 않도록 매우 조심해야한다. 이는 쉐도잉을 해버리기 때문에 예기치 못한 에러를 발생시킬 수 있다.

if문

go에서의 if문이 다른 언어와 가장 큰 차이점을 보이는 것은 감싸는 괄호가 없다는 것이다.

if condition {

}

그런데 go에서는 if문에서 다른 언어들과 달리 재밌는 재밌는 점이 있는데, 일반적으로 대부분의 언어는 if,else문의 중괄호 내에 선언된 모든 변수는 블록 내에서만 유효하다. go에서 추가된 것은 조건 if 혹은 else블록 내에서만 사용가능한 변수를 선언할 수 있다는 것이다. 이런 특성을 이용하여 다음의 코딩이 가능하다.

func main() {
	if n := rand.Intn(10); n == 0 {
		fmt.Println("That is too low")
	} else if n > 5 {
		fmt.Println("That's too big", n)
	} else {
		fmt.Println("That's a good number", n)
	}
}

if문의 조건이 들어가는 condition에 변수를 선언할 수 있다는 것이다. 그리고 이런 변수는 if, else if, else블록 내에서 모두 접근이 가능하다.

이런 특별한 범위 변수를 가지는 것은 매우 편리한데, 변수를 생성하는데 해당 변수가 필요한 영역에서만 사용 가능하도록 되기 때문이다. 일단 if/else문들이 마무리되면 n은 더이상 접근되지 않는다. 즉, if-else블록에 사용되기를 위해 특별한 변수를 외부에서 선언한 다음 끌고 올 필요가 없다는 것이다.

for

go의 유일한 반복문인 for문이다. go는 네가지 다른 방법으로 for 키워드를 사용할 수 있다.

  1. c언어 방식의 for
  2. 조건문만 있는 for(while방식)
  3. 무한루프의 for
  4. for-range

for의 완전한 구문

c언어 방식의 for문은 다음과 같다.

for i := 0; i < 10; i++ {
    fmt.Println(i)
}

0~9까지의 숫자를 출력한다. 사용법은 c언어와 같고 특별한 부분은 딱히 없다. 몇 가지 세부사항이 있는데

  1. 변수를 초기화하기 위해서 :=를 사용하도록 하자. 즉 var 키워드는 허용하지않는다.
  2. 둘째로 if문의 변수 선언과 마찬가지로 변수 쉐도잉이 될 수 있다. 만약 for문 외부의 다른 변수를 반복자로 쓰고 싶다면 :=을 쓰지 않도록 하자.

두 번째 부분은 조건식이므로 boolean만 나오면 된다.

세번째 부분은 증감이다.

for의 조건식만 사용하는 구문

while과 같은 방식이다.

for i < 100 {

}

for의 무한루프

for문에 조건식도 사용하지 않는 방법이다.

for {
    fmt.Println("hello")
}

참고로 go는 do-while문을 제공하지 않는다. 최소한 1번의 실행을 보장받아야 한다면 for에서 if문을 사용하도록 하자.

break와 continue

무한 루프를 벗어나거나, 반복의 상황에서 벗어나기 위해 사용하는 키워드로 breakcontinue이다.

for-range문

네 번째 for-range문은 go의 내장 타입의 요소를 순회하며 루프를 수행하는 for문이다. 다른 언어에서 iterator을 사용하여 반복하는 것과 비슷하다.

문자열, 배열, 슬라이스, 맵, 채널을 가지고 for-range 루프를 사용할 수 있다. 단, for-range는 go의 내장 타입 복합 타입이나 내장 복합 타입에 기반한 사용자 정의 타입로만 순회가 가능하다.

package main

import (
	"fmt"
)

func main() {
	evenVals := []int{1, 12, 3, 41}
	for i, v := range evenVals {
		fmt.Println(i, v)
	}
}

결과는 다음과 같다.

0 1
1 12
2 3
3 41

for-range 루프는 두 개의 변수를 얻는다는 부분이 재미있다. 첫번째 변수는 현재 순회 중인 자료구조에 있는 값의 위치이고, 두 번째는 해당 위치의 값이다. 그러나, 두 루프 변수의 관용적인 이름은 루프되는 항목에 따라 다르다. 가령 배열, 슬라이스, 문자열은 순회할 때 인덱스로 첫번째 인자가 사용오고, 맵은 key가 온다. 두번째 값이 온다.

두 개의 변수를 받기 때문에 루프에서 인덱스의 변수의 접근이 필요하지 않다면 밑줄(_)를 사용하여 변수를 생략할 수 있다.

func main() {
	value := []int{8, 2, 1, 4, 5, 6}
	for _, v := range value {
		fmt.Println(v)
	}
}

위와 같이 첫번째 인자인 인덱스를 사용하고 싶지않다면 _으로 생략하면 된다.

만약, 인덱스(혹은 키)만 사용하고 대응되는 값은 필요없다면 변수를 사용하지 않아도 된다.

func main() {
	value := []int{8, 2, 1, 4, 5, 6}
	for i := range value {
		fmt.Println(i)
	}
}

map순회

for-range루프로 map자료구조를 순회하는 방법에서 재미난 것이 있다.

func main() {
	m := map[string]int{
		"a": 1,
		"b": 3,
		"c": 2,
	}

	for i := 0; i < 3; i++ {
		fmt.Println("Loop", i)
		for k, v := range m {
			fmt.Println(k, v)
		}
	}
}

이 프로그램을 실행하면 매번 출력이 다르게 나온다.

Loop 0
a 1
b 3
c 2
Loop 1
a 1
b 3
c 2
Loop 2
a 1
b 3
c 2

키와 대응되는 값의 순서가 가끔 같을 수도 있지만, 다양하게 출력될 것이다. 이것은 실제 보안 기능이다. 이전 go버전에서는 맵에 같은 항목을 넣은 경우 맵의 키 순회 순서가 일반적으로 같았다. 이는 두 가지 문제를 야기한다.
1. 사람들은 순서가 고정된 것으로 가정하고 코드를 작성하는데, 이는 이상한 시점에 문제를 발생시킬 것이다.
2. 만약 맵이 항상 해시 값을 정확히 동일한 값을 만들고 서버에 맵으로 사용자 데이터를 저장하고 있는 경우라면, 모든 키가 동일한 버킷에 해시되어 특수 제작된 데이터를 보내는 해시 도스(Hash Dos) 공격으로 서버 속도를 느려지게 할 수 있다.

언급된 이 두 문제를 막기 위해 go는 맵 구현에 두 가지를 변경했다. 첫 번째는 맵을 위해 해시 알고리즘을 수정하여 맵 변수가 생성될 때 마다 무작위의 숫자를 포함하도록 했다. 두 번째는 맵을 for-range로 순회의 순서를 루프가 반복될 때 마다 조금씩 달라지게 했다. 이 두가지의 변경으로 해시 도스 공격을 더 어렵게 한다.

이 규칙에는 단 하나의 예외가 있다. 맵을 디버깅과 로깅을쉽게 하기 위해 포메팅 함수(fmt.Println과 같은 함수)는 항상 오름차순으로 맵의 키를 출력한다.

문자열 순회

문자열에도 for-range 루프를 적용할 수 있다.

package main

import (
	"fmt"
)

func main() {
	samples := []string{"hello", "apple!"}
	for _, sample := range samples {
		for i, r := range sample {
			fmt.Println(i, r, string(r))
		}
	}
	fmt.Println()
}

다음의 결과는 아래와 같다.

0 104 h
1 101 e
2 108 l
3 108 l
4 111 o
0 97 a
1 112 p
2 112 p
3 9829 ♥
6 108 l
7 101 e
8 33 !

첫번째 열은 인덱스를 두 번째 열은 문자의 숫자값, 세번째는 문자의 숫자값을 문자열로 변환한 값이다.

중요한 것은 for-range로 순회하는 것은 rune을 순회하는 것이지 byte를 순회하는 것이 아니다. 이는 for-range로 문자열에 여러 바이트에 걸친 룬을 처리할 때 UTF-8표현을 단일 32비트 숫자(rune자체가 int32이기 때문)로 변환하고 값에 할당한다. for-range루프에서 만약 UTF-8값이 아닌 것을 처리할 때는 유니코드 대체 문자 16진수 oxffd로 반환된다. 따라서 이모티콘처럼 1바이트를 초과해서 사용하는 문자도 문제없이 출력이 된다.

단, 인덱스 부분은 문자의 바이트 시작 부분이다.

3 9829 ♥
6 108 l

하트 이모티콘은 3바이트를 사용하므로 인덱스 부분은 3이다. 단, 값은 룬으로 출력되기 때문에 하트 그대로가 나오는 것이다.

정리하면, for-range루프로 문자열의 룬을 순서대로 접근할 수 있다. 단, 인덱스로 반환되는 값은 문자 시작 부분의 바이트 수이다.

for-range의 값은 복사본

for-range루프를 순회할 때 반복자로 사용되는 인덱스와 값은 복사한 값이다. 어떤 타입이든 포인터가 아닌 이상 가져온 값의 변수를 수정하더라도 복합 타입에 있던 값이 변경되진 않는다.

package main

import "fmt"

type People struct {
	name string
	age  int
}

func main() {
	temp1 := People{
		name: "hello",
		age:  12,
	}
	temp2 := People{
		name: "world",
		age:  20,
	}
	peopleTeam := []People{temp1, temp2}
	for _, v := range peopleTeam {
		v.name = "gyu"
		v.age = 99
	}
	fmt.Println(peopleTeam) // [{hello 12} {world 20}]
}

물론 만약 포인터라면 값을 바꿀 것이다. 위의 경우는 포인터가 아닌 값이기 때문에 반복자에 복사될 뿐 원본에 영향을 주진 않는 것이다.

for문 레이블링

중첩된 for문이 너무 깊을 때 빠져나오고 싶으면 label을 이용하여 종료하거나 건너뛰기를 할 수 있다. 만약 continue를 사용하면 이전의 루프 정보를 반복자들에 남기고, break를 사용하면 진짜 루프를 빠져나오기 때문에 반복문의 밖으로 나가게 된다.

func main() {
	samples := []string{"hello", "apple!"}
outer:
	for _, sample := range samples {
		for i, r := range sample {
			fmt.Println(i, r, string(r))
			if r == 'l' {
				continue outer
			}
		}
		fmt.Println()
	}
}

다음의 코드를 실행하면 아래의 결과를 얻을 수 있다.

0 104 h
1 101 e
2 108 l
0 97 a
1 112 p
2 112 p
3 108 l

그러나 레이블이 있는 중첩 for문은 매우 드물기도하고 가독성이 그렇게 좋진않아 자주 쓰이진 않는다.

switch 문

go도 switch-case문을 제공한다.

package main

import "fmt"

func main() {
	words := []string{"a", "cow", "smile", "gopher", "octopus", "anthropologist"}
	for _, word := range words {
		switch size := len(word); size {
		case 1, 2, 3, 4:
			fmt.Println(word, "is a short word!")
		case 5:
			fmt.Println(word, "is exactly the right length:", size)
		case 6, 7, 8, 9:
		default:
			fmt.Println(word, "is a long word!")
		}
	}
}

해당 코드를 실행하면 다음과 같은 결과를 볼 수 있다.

a is a short word!
cow is a short word!
smile is exactly the right length: 5
anthropologist is a long word!

if문과 마찬가지로 switch문에서 비교가 되는 값을 감쌀 필요가 없다. 재밌는 것은 switch문의 블럭 외부에 있는 for문의 변수인 word에도 접근이 가능하다는 것이다.

모든 case문은 중괄호 내에 들어가 있지만 case문이 구성되는 내용에는 중괄호를 넣지 않는다.

c언어와 달리 switch-case문은 모든 case문 마지막에 break를 넣을 필요가 없다. go는 기본적으로 case가 하나 실행되면 해당 case만 실행된다. 따라서, case 6, 7, 8, 9:의 경우는 아무것도 실행되지 않는다.

물론 go에서는 하나의 case 다음 case를 계속해서 수행할 수 있도록 fallthrough 키워드를 가지고 있다. 그러나 이 키워드를 사용하는 것부터가 이미 switch-case문의 조건들이 잘못되었다는 것이다.

switch-cae문에는 ==로 비교 가능한 조건들이면 모두 가능하다.

일반 switch문과 같이 공백 switch문에 일부러써 간단한 변수 선을 선택적으로 포함할 수 있다. 하지만 일반 switch문과 다르게 case문에 로직 테스트를 넣을 수 있다.

a := 2
switch {
case a == 2:
	fmt.Println("a is 2")
case a == 3:
	fmt.Println("a is 3")
case a == 4:
	fmt.Println("a is 4")
default:
	fmt.Println("a is ", a)
}	

해당 코드는 표현식 switch를 사용하면 더 좋다.

switch a {
case 2:
	fmt.Println("a is 2")
case 3:
	fmt.Println("a is 3")
case 4:
	fmt.Println("a is 4")
default:
	fmt.Println("a is ", a)
}

사실 if문이든 switch문이든 너무 많이 사용하면 좋을게 없다. 코드 가독성을 떨어뜨리고 코드를 더욱 복잡하게 만든다. 최대한 의존성을 주입하여 다른 configuration 상황을 유연하게 대처할 수 있도록 하는 것이 좋다. 만약 이런 것이 어렵다면 switch-case문은 case간 조건이 되도록 서로 관련이 있는 것으로 되어있는게 좋다. 가령 변수 a의 크기에 대한 조건이 필요하다면 switch-case가 좋다. 그런데, 그런것이 아닌 여러 변수들이 여러 단계를 거쳐야하는 조건이라면 if-else if-else가 훨씬 더 좋다.

goto 문

go의 4번째 제어문으로 goto문이 있다. go에서는 goto문은 코드의 레이블이 지정된 줄을 명시하고 실행이 해당 라인으로 이동하도록한다. 하지만 어디든 이동할 수 있는 것은 아니다. go는 변수 선언을 건너 뛰거나 내부 혹은 병렬 블록으로 바로 이동하는 것은 금지한다.

package main

import "fmt"

func main() {
	a := 10
	goto skip
	b := 20
skip:
	c := 30
	fmt.Println(a, b, c)
	if c > a {
		goto inner
	}
	if a < b {
	inner:
		fmt.Println("a is less than b")
	}
}

해당 프로그램을 실행하면 다음과 같은 오류를 볼 수 있다.

./main.go:7:7: goto skip jumps over declaration of b at ./main.go:8:4
./main.go:13:8: goto inner jumps into block starting at ./main.go:15:11

대부분은 사용하지 말자. 레이블 지정된 break, continue문을 사용하여 중첩된 루프에서 벗어나거나 순회를 건너뛸 수 있다. 아래는 goto를 사용하는 유일한 경우를 보여준다.

func main() {
	a := rand.Intn(10)
	for a < 100 {
		if a%5 == 0 {
			goto done
		}
		a = a*2 + 1
	}
	fmt.Println("Do someething")
done:
	fmt.Println("don complicated")
	fmt.Println(a)
}

위와 같이 반복에서 벗어나기위해 goto를 사용할 수 있지만 flag를 사용하여 루프에서 벗어나는 방법이 더 좋다.

0개의 댓글