3장 복합 타입

2024.03.19

  • Go에서 배열을 직접 사용하는 경우는 드물다.

3.1 배열

  • Go에서 배열의 크기를 배열 타입의 일부로 간주하는 제한이 있기 때문
    • 예를 들면 배열을 3[int]로 선언한 것은 4[int]와 다른 타입으로 만든다. 이것은 배열의 크기를 지정하기 위해 변수를 이용할 수 없다는 것이다.
    • 타입은 실행 중이 아니라 컴파일 과정에서 반드시 해석이 되어야하기 때문이다.
  • 동일한 타입을 가진 다른 크기의 배열 간에 타입 변환을 시도할 수도 없다.
  • 크기가 다른 배열을 서로 반환할 수 없기 때문에, 어떤 크기의 배열로도 실행 가능한 함수를 작성할 수 없으며, 동일한 변수에 크기가 다른 배열을 할당할 수 없다.
  • 정확히 미리 필요한 크기를 아는 경우가 아니라면 배열을 사용하지 않도록 한다.
  • 배열의 모든 요소는 지정된 타입이어야 한다. ( 모든 요소가 항상 같은 타입이어야 한다 )
  • 배열에 초기 값을 주려면 리터럴로 넣을 수 있다.
var x = [3]int{10, 20, 30}
  • 희소 배열(대부분 요소의 값이 0으로 설정된 배열)을 만든다면 배열 리터럴 내에 지정된 인덱스의 값만 설정할 수 있다.
var x = [12]int{1, 5: 4, 6, 10: 100, 15}

// [1, 0, 0, 0, 0, 4, 6, 0, 0, 0, 100, 15]
  • 범위를 넘어서는 값을 가진 변수를 통해 배열을 읽거나 쓰기를 시도한다면 컴파일은 되겠지만, 실행 중에 패닉과 함께 실패할 것이다.

3.2 슬라이스

  • 일련의 값을 가지고 있는 자료구조를 구성할 때, 대부분 슬라이스를 사용한다.
    • 유용하게 사용하는 이유는 슬라이스의 크기는 해당 타입의 일부가 아니기 때문이다. ( 배열의 제약을 제거한 것 )
    • 단일 함수로 어떤 크기의 배열로든 처리 가능한 함수를 작성할 수 있고, 필요한 만큼 크기를 키울 수 있다.
  • 슬라이스를 선언할 때, 슬라이스의 크기를 지정할 필요가 없다.
var x = []int{10, 20, 30}
  • 배열과 같이 크기를 넘어서거나 음수의 인덱스로는 읽기와 쓰기가 가능하지 않다.
var x []int
  • 위와 같이 정수 슬라이스를 선언한다면, 어떠한 값도 할당되지 않았기 때문에, x에는 슬라이스를 위한 제로 값으로 할당된다. 여기서 제로 값은 0이 아니라 nil이라는 값이다.
    • Go에서 nil은 몇몇 타입의 값의 부재를 표현한 식별자이다.
  • 슬라이스가 nil인 것은 어떤 요소도 갖고 있지 않다는 것이다.
  • 슬라이스는 비교가 불가능한 타입이다. == 연산자를 사용하거나 != 사용하면 컴파일 오류가 발생한다.
  • 슬라이스는 nil으로만 비교가 가능하다.
fmt.Println(x == nil) // true를 출력

3.2.1 len

  • Go의 내장 함수인 len 으로 슬라이스의 길이를 알 수 있다.
  • nil 값을 가진 슬라이스를 len 으로 전달하면 0을 반환한다.

3.2.2 append

  • 내장 함수 append 함수는 슬라이스에 새로운 요소를 추가한다.
  • 2개의 파라미터를 받는데, 하나는 타입을 가지는 슬라이스이고 다른 하나는 추가하려는 값이다.
var x []int
x = append(x, 10)
  • 한 번에 하나 이상의 값들을 추가할 수도 있다.
x = append(x, 4, 5, 6)
  • ... 연산자를 이용해 다른 슬라이스의 개별 요소들을 추가하여 확장할 수도 있다.
y := []int{20, 30, 40}
x = append(x, y...)
  • append 를 통해 반환된 값을 할당하지 않는다면 컴파일 오류가 날 것이다. Go는 값에 의한 호출(call by value) 방식을 사용하는 언어이기 때문이다.
  • append 로 전달된 슬라이스는 복사된 값이 함수로 전달된다. 이 함수는 복사된 슬라이스에 값들을 추가하고 추가된 복사본을 반환한다. 그렇기 때문에 함수 호출에 사용한 변수에 반환된 슬라이스를 다시 할당해 줘야 한다.

3.2.3 수용력

  • 슬라이스의 각 요소는 연속적인 메모리 공간에 할당될 것이고, 이런 할당은 값을 빠르게 읽고 쓰기가 가능하도록 한다. 모든 슬라이스는 수용력(capacity)을 가지는데, 예약된 연속적인 메모리 공간의 크기 값을 가진다.

  • 슬라이스에 하나 혹은 하나 이상의 값들을 추가할 때, 슬라이스의 뒤쪽에서 부터 채워진다. 추가된 각 값에 따라 슬라이스의 길이가 1씩 증가한다.

  • 길이가 수용할 만큼 증가한다면, 더 이상 값을 넣을 공간이 없게된다. 길이와 수용력이 같아진 시점에 값을 추가한다면, append 는 Go 런타임을 사용하여 더 큰 수용력을 가지는 새로운 슬라이스를 할당한다.

  • Go 런타임이 슬라이스의 수용력이 다 차면 대개 기존 수용력의 두 배만큼 증가시킨다.

  • 내장 함수 cap 함수는 현재 슬라이스의 수용력을 반환환다. 대부분 cap 의 사용은 새로운 데이터를 들이기에 충분한 공간이 슬라이스에 있는지를 확인하는 용도로 사용되거나 새로운 슬라이스를 할당하기 위해 make 함수를 호출하는 경우에 사용된다.

3.2.4 make

  • make 는 타입, 길이, 그리고 선택적으로 수용력을 지정하여 슬라이스를 만들 수 있다.
x := make([]int, 5)

// 길이 5, 수용력 5를 가지는 정수 슬라이스를 만든다.
// 길이가 5이기 때문에 x[0]에서 x[4]까지 접근 가능한 요소이며, 모두 0으로 초기화된다.
  • 초기 수용력을 지정하여 수행할 수도 있다.
x := make([]int, 5, 10)

// 길이 5, 수용력 10를 가지는 정수 슬라이스를 만든다.

3.2.5 슬라이스 선언

  • 슬라이스가 전혀 커질 일이 없다면(함수가 아무 것도 반환할 것이 없는 경우) nil 슬라이스를 만들기 위해 값의 할당이 없는 var 선언을 사용하자.
  • 슬라이스에 시작 값을 가지거나 슬라이스 값이 변경되지 않는 경우라면 슬라이스 리터럴을 사용하여 선언하도록 하자.
  • 슬라이스가 얼마나 커져야 하는지 잘 알고 있지만, 프로그램을 작성할 때 어떤 값인지 정확히 알 수 없다면 make 를 사용해보자.
  • GO 커뮤니티에서는 슬라이스 리터럴과 make 사용하여 슬라이스를 선언하는 방식으로 나뉜다.

3.2.6 슬라이스의 슬라이싱

  • 슬라이스 연산자는 슬라이스에서 슬라이스를 만들게 한다. 대괄호 내에 콜론(:)으로 구분하여 시작 오프셋과 마지막 오프셋으로 구성하여 사용한다.
    • 시작 오프셋을 생략한다면, 0으로 간주하고 마지막 오프셋을 생략하면 슬라이스의 마지막 인덱스로 간주한다.
x := []int{1, 2, 3, 4}
y := x[:2]
z := x[1:]
d := x[1:3]
e := x[:]

// x [1 2 3 4]
// y [1 2]
// z [2 3 4]
// d [2 3]
// e [1 2 3 4]

슬라이스는 때론 저장 공간을 공유

  • 슬라이스에서 슬라이스를 가져왔을 때, 실제 데이터의 복사를 만들지는 않는다. 메모리를 공유하는 두개의 변수를 가지게 되는 것이다.
  • 슬라이스의 슬라이싱은 append 와 함께 사용하면 혼란이 가중된다.
x := []int{1, 2, 3, 4}
y := x[:2]
fmt.Println(cap(x), cap(y))
y = append(y, 30)
fmt.Println("x:", x)
fmt.Println("y:", y)

// 4 4
// x: [1 2 30 4]
// y: [1 2 30]
  • 하위 슬라이스의 수용력은 원본 슬라이스의 수용력에서 하위 슬라이스 시작 오프셋만큼 뺀 값이 설정된다. 이는 원본 슬라이스의 사용되지 않은 모든 수용력은 만들어진 모든 하위 슬라이스에 공유가 된다는 의미이다.
  • x에서 y 슬라이스를 만들 때, 길이는 2로 설정했지만 수용력은 x와 동일한 4로 설정된다.
  • 수용력이 4이기 때문에, y에 끝에 값을 추가하는 것은 x의 세 번째 위치에 요소를 넣는다.
  • 복잡한 슬라이스 상황이 발생하지 않도록 하기 위해, 하위 슬라이스에 append 를 사용하지 않거나 append를 사용해도 덮어쓰기가 되지 않도록 하는 완전한 슬라이스 연산(full slice expression)을 사용하도록 하자.
  • 완전한 슬라이스 연산은 부모 슬라이스에서 파생된 하위 슬라이스에 얼마나 많은 메모리를 공유할 것인지 명확하게 해준다.
  • 완전한 슬라이스 연산은 하위 슬라이스를 위한 가용한 부모 슬라이스의 수용력의 마지막 위치를 지정하는 세 번째 인자를 가진다.
y := x[:2:2]
  • y는 2의 수용력을 가진다. 하위 슬라이스의 수용력을 해당 길이로 제한을 했기 때문에, 다른 슬라이스와 상호 작용 없이 새로운 슬라이스가 생성되어 y에 요소가 추가될 것이다.

3.2.7 배열을 슬라이스로 변환

  • 배열을 사용하고 있다면, 슬라이스 연산을 이용해서 배열로부터 슬라이스를 가져올 수 있다.
  • 이는 함수가 슬라이스만 인자로 받는 경우, 배열에서 변환하여 전달할 때 유용하게 사용할 수 있다.
  • 배열로부터 슬라이스를 만드는 것은 슬라이스에서 만드는 것과 마찬가지로 동일한 메모리 공유 속성을 가지게 된다.
x := [4]int{5, 6, 7, 8}
y := x[:2]

// x [10 6 7 8]
// y [10 6]

3.2.8 copy

  • 원본 슬라이스로부터 독립적인 슬라이스를 생성할 필요가 있다면, 내장 함수 copy 를 사용하자.
  • copy 함수는 2개의 파라미터를 가진다. 첫 번째는 대상 슬라이스고, 두 번째는 원본 슬라이스이다.
x := []int{1, 2, 3, 4}
y := make([]int, 4)
num := copy(y, x)

// y [1 2 3 4]
// num 4
  • 더 작은 슬라이스를 기준으로 원본 슬라이스에서 최대한 값을 복사할 것이고 실제 복사된 요소의 개수를 반환할 것이다.
x := []int{1, 2, 3, 4}
y := make([]int, 2)
num := copy(y, x)

// y [1 2]
// num 2
  • 원본 슬라이스에서 중간에서부터 복사도 가능하다
x := []int{1, 2, 3, 4}
y := make([]int, 2)
num := copy(y, x[2:])

// y [3 4]
// num 2
  • 배열을 copy 함수의 원본 혹은 대상의 인자로도 사용할 수 있다.

3.3 문자열과 룬 그리고 바이트

  • Go는 문자열을 표현하기 위해 일련의 바이트를 사용한다.
  • 언어 스펙에 따르면, Go 소스 코드는 항상 UTF-8로 쓰여진다. 문자열 리터럴에 16진수 이스케이프를 사용하지 않는다면, 문자열 리터럴은 UTF-8로 쓰여진다.
  • 문자열도 인덱스 표현으로 값을 꺼내올 수 있고, 슬라이스 표기법도 사용할 수 있다.
var s string = "Hello there"
var b byte = s[6]      // t
var c string = s[4:7]  // "o t"
var d string = s[:5]   // "Hello"
var e string = s[6:]   // "there"
  • 내장 함수 len 에 문자열을 넘겨, 문자열의 길이를 파악할 수 있다.
  • len 을 통해 반환된 길이는 코드 단위가 아니라 바이트 단위라는 것을 알 수 있다.
var s string = "Helllo 😃"
fmt.Println(len(s))

// 7이 아닌 10을 출력한다. 이모티콘을 표현하기 위해 4바이트를 사용하였기 때문에,
  • 룬, 문자열, 바이트 사이에 복잡한 관계 때문에, Go는 이런 타입들 간에 변환을 할 수 있도록 하는 기능을 제공한다. 단일 룬 혹은 바이트는 문자열로 변환이 가능하다.
var a rune    = 'x'
var s string  = string(a)
var b byte    = 'y'
var s2 string = string(b)

var q string  = "Hello, 😃"
var bs []byte = []byte(s)
var rs []rune = []rune(s)

 // bs [72 101 108 108 111 44 32 240 159 140 158]
 // rs [72 101 108 108 111 44 32 127774]
  • Go에서 대부분의 데이터는 일련의 바이트로 읽거나 쓸 수 있어서, 일반 문자열은 바이트 슬라이스로 타입 변환이 가능하다. 룬 슬라이스로 변환은 드문 경우이다.

3.4 맵

  • 맵을 선언할 수 있는 몇 가지 방법이 있다.
var nilMap map[string]int
// 길이 0, 제로 값은 nil, 맵에 값을 쓰려고 한다면 패닉 발생

totalWins := map[string]int{}
// 길이 0, 맵은 비어 있지만 맵의 값을 읽고 쓸 수 있음

teams := map[string][]string {
  "Orcas": []string{"Fred", "Ralph"},
  "Lions": []string{"Sarah", "Peter"},
}
// 비어 있지 않은 맵 리터럴
  • 키-값 쌍이 얼마나 들어갈지는 알고 있지만, 정확히 어떤 값이 들어갈지 모른다면 make 를 이용해서 기본 크기를 지정하여 맵을 생성할 수 있다.
ages := make(map[int][]string, 10)
  • 맵은 키-값 쌍이 추가가 되면 자동으로 커진다.
  • 맵에 넣을 키-값 쌍의 데이터가 어느정도 되는지 파악이 된다면, make를 통해 특정한 크기로 초기화하여 생성할 수 있다.
  • len 함수에 맵을 넘긴다면 키-값 쌍이 맵에 몇 개가 있는지를 알려준다.
  • 맵의 제로 값은 nil 이다.
  • 맵은 비교 가능하지 않다. nil 과는 비교가 가능하다.
  • 맵의 키는 모든 비교 가능한 타입이 될 수 있다. 이것은 맵의 키로써 슬라이스나 맵이 될 수 없다는 것을 의미한다.

콤마 OK 관용구

  • 맵에 키가 있는지 확인해야 하는 경우, 콤마 OK 관용구(comma ok idiom)을 사용하면 편리하다.
    • 맵에 키가 없어 제로 값을 반환하는 경우와 키에 해당하는 값으로 0을 반환한 것인지 구분할 수 있다.
m := map[string]int{
    "hello": 5,
    "world": 0,
}

v, ok := m["hello"]
fmt.Println(v, ok)

v, ok := m["world"]
fmt.Println(v, ok)

v, ok := m["goodbye"]
fmt.Println(v, ok)

// 5 true
// 0 true
// 0 false
  • 키-값 쌍은 내장 함수 delete 를 이용하여 맵에서 삭제될 수 있다.
m := map[string]int{
    "hello": 5,
    "world": 0,
}

delete(m, "hello") // 해당 키-값 쌍이 제거된다.

delete(m, "goodbye") // 아무 일도 일어나지 않는다.

3.5 구조체

  • 여러 데이터 타입을 함께 구성할 때, sturct 를 정의하여 사용할 수 있다.
  • type 키워드로 구조체 타입의 이름을 지정하고, 키워드 struct 다음에 중괄호({})로 구조체를 정의할 수 있다.
type person struct {
    name string
    age  int
    pet  string
}
  • 함수 내에서 선언된 구조체 타입은 함수 안에서만 사용 가능하다.
  • 구조체의 제로 값은 구조체가 가지는 모든 항목이 각각 제로 값으로 설정되는 것이다.
  • 구조체 리터럴을 사용할 때 구조체의 모든 항목에 대응되는 값을 지정해주어야 한다.
julia := person{
  "Julia",
  40,
  "cat",
}
// 구조체 항목 순서대로 값들을 나열해야 한다.

beth := person{
  age: 30,
  name: "Beth",
}
// 순서와 상관없이 항목의 값을 넣을 수 있다.
// 특정 항목을 빼도 된다.
  • 작은 구조체라면 단순한 구조체 리터럴 방식을 사용해고 좋다. 다른 경우라면, 키 이름을 명시하도록 하자.

3.5.1 익명 구조체

  • 구조체 타입 이름을 지정하지 않고 선언하여 사용할 수 있다. 이것을 익명 구조체(anonymous struct)라 부른다.
var person struct {
  name string
  age  int
  pet  string
}

person.name = "bob"

pet := struct {
  name string
  kind string
}{
  name: "Fido",
  kind: "dog",
}
  • 익명 구조체는 마샬링(marshaling) 혹은 언마샬링(unmarshaling)할 때 사용하면 좋다.
    • 외부 데이터를 구조체로 전환하거나 구조체를 외부 데이터(JSON 같은)로 전환하는 것

3.5.2 구조체 비교와 변환

  • 구조체가 비교 가능한지 여부는 구조체의 항목에 따라 다르다.
    • 모든 구조체 내의 항목이 비교 가능한 타입으로 구성되어 있다면 비교가 가능하다.
  • Go는 두 개의 구조체가 같은 이름, 순서, 타입으로 구성되어 있다면 구조체 간에 타입 변환을 수행할 수 있다.