6장 배열, 슬라이스, 맵

3장에서는 Go의 기본 타입에 관해 배웠다. 6장에서는 배열, 슬라이스, 맵이라는 세 가지 내장 타입에 관해 살펴보겠다.

6.1 배열

배열은 길이가 고정된, 번호가 매겨진 단일 타입 원소의 나열이다. Go에서는 다음과 같은 형태를 띤다.

var x [5]int

x는 5개의 int 타입으로 구성된 배열의 예다. 다음 프로그램을 실행해 보자.

package main

import "fmt"

func main() {
    var x [5]int
    x[4] = 100
    fmt.Println(x)
}

출력 결과는 다음과 같다.

[0 0 0 0 100]

x[4] = 100은 "배열 x의 5번째 요소를 100으로 설정하라"로 읽으면 된다. 여기서 x[4]가 4번째 요소가 아니라 5번째 요소를 나타내는 것이 이상해 보일 수도 있겠지만 문자열과 마찬가지로 배열도 0부터 인덱스가 시작한다. 배열에도 비슷한 방식으로 접근한다. fmt.Println(x)fmt.Println(x[4])로 수정하면 100이 출력될 것이다.

다음은 배열을 사용하는 프로그램의 예다.

func main() {
    var x [5]float64
    x[0] = 98
    x[1] = 93
    x[2] = 77
    x[3] = 82
    x[4] = 83

    var total float64 = 0
    for i := 0; i < 5; i++ {
        total += x[i]
    }
    fmt.Println(total / 5)
}

이 프로그램은 시험 점수의 평균을 계산한다. 이 프로그램을 실행하면 86.6이 출력될 것이다. 프로그램이 어떻게 동작하는지 살펴보자.

이 프로그램은 동작하지만 Go에서는 이 프로그램을 개선할 수 있는 기능을 제공한다. 먼저 i < 5total / 5라고 돼 있는 두 부분을 눈여겨보자. 시험 점수의 개수를 5개에서 6개로 바꿨다고 해보자. 그럼 이 두 부분들을 모두 수정해야 할 것이다. 대신 배열의 길이를 사용하는 편이 더 낫다.

var total float64 = 0
for i := 0; i < len(x); i++ {
    total += x[i]
}
fmt.Println(total / len(x))

직접 프로그램을 이렇게 수정하고 실행해 보자. 그럼 다음과 같은 오류가 나타날 것이다.

$ go run tmp.go
# command-line-arguments
.\tmp.go:19: invalid operation: total / 5
(mismatched types float64 and int)

이 문제는 len(x)total의 타입이 서로 다르다는 것이다. totalfloat64인 반면 len(x)int다. 따라서 len(x)float64로 바꿔야 한다.

fmt.Println(total / float64(len(x)))

이것은 형변환(type conversion)의 한 예다. 일반적으로 타입을 변환할 때는 함수처럼 타입명을 사용하면 된다.

이 프로그램에서 수정할 수 있는 또 한 가지 부분은 특별한 형태의 for 루프를 사용하는 것이다.

var total float64 = 0
for i, value := range x {
    total += value
}
fmt.Println(total / float64(len(x)))

이 for 루프에서 i는 배열에서의 현재 위치를 나타내고 valuex[i]와 같다. 아울러 여기서는 순회하고 싶은 변수명 다음에 range라는 키워드를 사용하고 있다.

이 프로그램을 실행하면 다음과 같은 오류가 발생할 것이다.

$ go run tmp.go
# command-line-arguments
.\tmp.go:16: i declared and not used

Go 컴파일러는 사용하지 않은 변수를 생성하지 못하게 한다. 여기서는 반복문 내에서 i를 사용하지 않으므로 다음과 같이 수정해야 한다.

var total float64 = 0
for _, value := range x {
    total += value
}
fmt.Println(total / float64(len(x)))

_(언더스코어)는 컴파일러에게 이것이 필요하지 않다고 알려주는 데 사용한다. (이 경우 순회용 변수가 필요하지 않다.)

Go에서는 배열을 생성하는 더 짧은 문법도 제공한다.

x := [5]float64{ 98, 93, 77, 82, 83 }

Go에서 타입을 파악할 수 있기 때문에 더는 타입을 지정할 필요가 없다. 간혹 배열을 이런 식으로 생성하면 한 줄에 맞춰 넣기가 힘들 때도 있으므로 Go에서는 다음과 같은 식으로 나눠 써도 된다.

x := [5]float64{
    98,
    93,
    77,
    82,
    83,
}

여기서 83 다음에 ,가 따라 나오는 것을 눈여겨보자. Go에서는 이렇게 하는 것이 필수이고, 이렇게 하면 특정 줄을 주석으로 처리해 배열의 요소를 손쉽게 제거할 수 있다.

x := [4]float64{
    98,
    93,
    77,
    82,
    // 83,
}

이 예제는 배열과 관련한 큰 문제를 하나 보여준다. 바로 배열의 길이가 고정돼 있다는 것과 배열의 타입명과 관련된 문제다. 마지막 항목을 제거하기 위해 실제로 타입도 변경해야 했다. Go에서는 이 문제의 해법으로 슬라이스라는 또 다른 타입을 사용할 수 있다.

6.2 슬라이스

슬라이스(slice)는 배열의 일부다. 배열과 마찬가지로 슬라이스도 인덱스를 통해 접근할 수 있고 길이가 있다. 하지만 배열과 달리 슬라이스의 길이는 바뀔 수 있다. 다음은 슬라이스의 예다.

var x []float64

슬라이스와 배열의 유일한 차이점은 대괄호 사이에 길이가 없다는 것이다. 이 경우 x는 길이가 0인 상태로 생성된다.

슬라이스를 생성하고 싶다면 내장 함수인 make를 사용하면 된다.

x := make([]float64, 5)

이렇게 하면 기저에 길이가 5인 float64 배열과 연관된 슬라이스가 만들어진다. 슬라이스는 항상 그것과 연관된 배열이 있고, 해당 배열보다 절대로 길어질 수는 없지만 더 작아질 수는 있다. make 함수에는 세 번째 매개변수를 전달할 수도 있다.

x := make([]float64, 5, 10)

10은 이 슬라이스가 가리키는 기저 배열이 점유하는 공간을 나타낸다.

슬라이스

슬라이스를 만드는 또 다른 방법은 [low : high] 수식을 사용하는 것이다.

arr := []float64{1,2,3,4,5}
x := arr[0:5]

low는 슬라이스가 시작되는 인덱스이고, high는 슬라이스가 끝나는 인덱스다(인덱스 자체는 포함하지 않는다). 예를 들어 arr[0:5][1,2,3,4,5]를 반환하는 데 반해 arr[1:4][2,3,4]를 반환한다.

편의를 위해 lowhigh를 생략하거나 심지어 lowhigh를 모두 생략할 수도 있다. arr[0:]arr[0:len(arr)]와 같고 arr[:5]arr[0:5]와 같으며, arr[:]arr[0:len(arr)]과 같다.

슬라이스 함수

Go에는 appendcopy라는 슬라이스를 사용하는 데 도움이 되는 두 가지 내장 함수가 포함돼 있다. 다음은 append를 사용하는 예제다.

func main() {
    slice1 := []int{1,2,3}
    slice2 := append(slice1, 4, 5)
    fmt.Println(slice1, slice2)
}

이 프로그램을 실행하면 slice1[1,2,3]이 되고 slice2[1,2,3,4,5]가 된다. append는 기존 슬라이스(첫 번째 인자)를 가져와서 그다음에 이어지는 인자를 모두 거기에 덧붙이는 식으로 새 슬라이스를 생성한다.

다음은 copy를 사용하는 예제다.

func main() {
    slice1 := []int{1,2,3}
    slice2 := make([]int, 2)
    copy(slice2, slice1)
    fmt.Println(slice1, slice2)
}

이 프로그램을 실행하면 slice1[1,2,3]이 되고 slice2[1,2]가 된다. slice1의 내용은 slice2로 복사되는데, slice2에는 두 요소가 들어갈 만한 공간밖에 없어서 slice1의 첫 두 요소만 복사된다.

6.3 맵

맵(map)은 순서가 없는 키-값(key-value) 쌍의 집합이다. 맵은 연관 배열 또는 해시 테이블(hash table), 딕셔너리(dictionary)로도 알려져 있으며, 연관 키를 통해 값을 찾는 데 사용된다. 다음은 Go에서 맵을 사용하는 예제다.

var x map[string]int

맵 타입은 map 키워드에 이어 키 타입을 대괄호 안에, 그리고 마지막으로 값 타입을 지정해서 나타낸다. 이것을 소리 내서 읽으면 "xint에 대한 string의 맵이다"가 된다. 배열과 슬라이스와 마찬가지로 맵에도 대괄호를 이용해 접근할 수 있다. 다음 프로그램을 실행해 보자.

var x map[string]int
x["key"] = 10
fmt.Println(x)

그러면 다음과 같은 오류가 출력될 것이다.

panic: runtime error: assignment to entry in nil map
goroutine 1 [running]:
main.main()
  main.go:7 +0x4d

goroutine 2 [syscall]:
created by runtime.main
C:/Users/ADMINI~1/AppData/Local/Temp/2/bindit269497170/go/src/pkg/runtime/proc.c:221exit status 2

지금까지는 컴파일 시점 오류(compile-time error)만 봤다. 이것은 런타임 오류의 한 예다. 컴파일 시점 오류가 프로그램을 컴파일할 때 발생하는 데 반해 이름에서 알 수 있듯이 런타임 오류는 프로그램을 실행할 때 발생한다. 이 프로그램에서 발생한 문제는 맵을 사용하기 전에 초기화해야 한다는 것이다. 즉, 다음과 같이 작성해야 했다.

x := make(map[string]int)
x["key"] = 10
fmt.Println(x["key"])

이 프로그램을 실행하면 10이 출력될 것이다. x["key"] = 10이라는 문장은 배열에서 본 것과 비슷하지만 키가 정수가 아닌 문자열인데, 이는 맵의 키 타입이 string이기 때문이다. 키 타입을 int로 지정해 맵을 생성할 수도 있다.

x := make(map[int]int)
x[1] = 10
fmt.Println(x[1])

배열과 훨씬 더 비슷해 보이지만 몇 가지 차이점이 있다. 먼저 맵의 길이(len(x)로 구할 수 있는)가 새 항목을 맵에 추가할 때마다 바뀔 수 있다. 맵이 처음 생성되면 길이가 0이고, x[1] = 10을 실행하고 나면 길이가 1이 된다. 두 번째로 맵은 순차적이지 않다. x[1]을 실행할 경우 배열이라면 반드시 x[0]이 있다는 뜻이 되겠지만 맵에는 이 같은 요건이 없다. 내장 delete 함수를 이용해 맵에서 항목을 삭제할 수도 있다. delete(x, 1) 맵을 사용하는 다음 예제 프로그램을 살펴보자. package main

import "fmt"

func main() {
    elements := make(map[string]string)
    elements["H"] = "Hydrogen"
    elements["He"] = "Helium"
    elements["Li"] = "Lithium"
    elements["Be"] = "Beryllium"
    elements["B"] = "Boron"
    elements["C"] = "Carbon"
    elements["N"] = "Nitrogen"
    elements["O"] = "Oxygen"
    elements["F"] = "Fluorine"
    elements["Ne"] = "Neon"
    fmt.Println(elements["Li"])
}

elements는 첫 10개의 화학 원소를 나타내는 맵으로서 원소 기호를 인덱스로 사용한다. 이것은 맵을 룩업 테이블(lookup table)이나 딕셔너리(dictionary)로 사용하는 굉장히 보편적인 맵 활용법이다. 존재하지 않는 원소를 찾는다고 가정해 보자.

fmt.Println(elements["Un"])

이 코드를 실행하면 아무것도 반환되지 않을 것이다. 엄밀히 말해서 맵은 값 타입에 대해 0 값(문자열의 경우 빈 문자열에 해당한다)을 반환한다. 조건문으로 0 값을 검사할 수도 있지만(elements["Un"] == "") Go에서는 더 나은 방법을 제공한다.

name, ok := elements["Un"]
fmt.Println(name, ok)

맵의 요소에 접근할 경우 하나가 아닌 두 개의 값이 반환될 수 있다. 첫 번째 값은 탐색의 결과를, 두 번째 값은 탐색의 성공 여부를 나타낸다. Go에서는 다음과 같은 코드를 자주 볼 수 있다.

if name, ok := elements["Un"]; ok {
    fmt.Println(name, ok)
}

먼저 맵에서 값을 구하려고 한 다음, 값을 성공적으로 구할 경우 블록 안의 코드를 실행한다. 배열에서 본 것과 마찬가지로 맵을 생성하는 더 짧은 방법이 있다.

elements := map[string]string{
    "H": "Hydrogen",
    "He": "Helium",
    "Li": "Lithium",
    "Be": "Beryllium",
    "B": "Boron",
    "C": "Carbon",
    "N": "Nitrogen",
    "O": "Oxygen",
    "F": "Fluorine",
    "Ne": "Neon",
}

맵은 일반적인 정보를 저장하는 데 자주 사용된다. 단순히 원소의 이름을 저장하는 대신 원소의 표준 상태(실온에서의 물질의 상태)도 저장하게끔 프로그램을 수정해 보자.

func main() {
    elements := map[string]map[string]string{
          "H": map[string]string{
                "name":"Hydrogen",
                "state":"gas",
          },
          "He": map[string]string{
                "name":"Helium",
                "state":"gas",
          },
          "Li": map[string]string{
                "name":"Lithium",
                "state":"solid",
          },
          "Be": map[string]string{
                "name":"Beryllium",
                "state":"solid",
          },
          "B":  map[string]string{
                "name":"Boron",
                "state":"solid",
          },
          "C":  map[string]string{
                "name":"Carbon",
                "state":"solid",
          },
          "N":  map[string]string{
                "name":"Nitrogen",
                "state":"gas",
          },
          "O":  map[string]string{
                "name":"Oxygen",
                "state":"gas",
          },
          "F":  map[string]string{
                "name":"Fluorine",
                "state":"gas",
          },
          "Ne":  map[string]string{
                "name":"Neon",
                "state":"gas",
          },
    }

    if el, ok := elements["Li"]; ok {
           fmt.Println(el["name"], el["state"])
    } 
}

맵의 타입이 map[string]string에서 map[string]map[string]string으로 바뀐 것을 눈여겨보자. 이제 문자열에 대한 문자열의 맵에 대한 문자열 의 맵이 만들어졌다. 바깥쪽 맵은 원소 기호를 기반으로 한 룩업 테이블로 사용되고 안쪽 맵은 원소의 일반적인 정보를 저장하는 데 사용된다. 맵은 이와 같이 자주 사용되기도 하지만 9장에서는 구조적인 정보를 저장하는 더 나은 방법을 살펴보겠다.

연습 문제

  1. 배열이나 슬라이스의 4번째 요소에 접근하는 방법은 무엇인가?
  2. make([]int, 3, 9)로 생성한 슬라이스의 길이는 얼마인가?
  3. 다음과 같은 배열이 있을 때

    x := [6]string{"a","b","c","d","e","f"}
    

    x[2:5]의 결과는 무엇인가?

  4. 다음 리스트에서 가장 작은 숫자를 찾는 프로그램을 작성하라.

    x := []int{
       48,96,86,68,
       57,82,63,70,
       37,34,83,27,
       19,97, 9,17,
    }
    
← 이전다음 →