Third Day with Go-Lang

3 minute read

클로저 (Closure)

Go 언어에서 함수는 Clousre 로 사용될 수도 있다. Closure 는 함수 바깥에 있는 변수를 참조하는 Function Value 를 일컫는데, 이때 함수는 함수 밖의 변수를 마치 함수 안으로 끌어들인 듯 그 변수를 읽고 쓸 수 있게 된다.

아래 예제에서 nextValue() 함수는 int 를 리턴하는 익명함수 (func() int) 를 리턴하는 함수이다. 그런데 여기서 이 익명함수가 함수 바깥에 있는 변수 i 를 참조하고 있다.

예제에서 next := nextValue() 에서 Closure 함수를 next 라는 변수에 할당한 후, 계속 next() 를 3번 호출하는데 이 때마다 Closure 내의 변수 i 는 계속 증가된 값을 갖고 있게 된다. 이는 마치 next 라는 함수값이 변수 i 를 내부에 유지하고 있는 모양새이다. 하지만 anotherNext := nextValue() 같이 새로운 Closure 함수값을 생성한다면, 변수 i 는 초기 0을 갖게 되므로 다시 1부터 카운팅을 하게 된다.

package main

type retVal func() int

func nextValue() retVal {
  i := 0
  return func() int {
    return i++
  }
}

func main() {
  next := nextValue()

  println(next())
  println(next())
  println(next())

  anotherNext := nextValue()
  println(anotherNext())
  println(anotherNext())
}

컬렉션 - 배열

배열의 선언은 var Variable_Name [Size]Type 의 형태로 이루어진다. Go 에서 배열의 Size 는 Type 을 구성하는 한 요소이다. 따라서 [3]int[5]int 는 서로 다른 타입으로 인식된다.

package main

func main() {
  var a [3]int
  a[0] = 1
  a[1] = 2
  a[2] = 3
  println(a[1]) // 2 출력
}
  • 배열의 초기화

배열을 정의할 때 초기값을 설정할 수 있는데, [Size]Type 뒤에 {} 괄호를 두고 초기값을 순서대로 적으면 된다. 만약 초기화 과정에서 [...] 를 사용해 배열크기를 생략하면 자동으로 초기화 요소 숫자만큼 배열 크기가 정해진다.

var a1 = [3]int{1, 2, 3}
var a3 = [...]int{1, 2, 3}
  • 다차원 배열

Go 언어는 다차원 배열을 지원한다. 사용은 다른 언어와 동일하다.

func main() {
  var a = [2][3]int {
    {1, 2, 3},
    {4, 5, 6}, // 끝에 콤마 추가
  }
  println(a[1][2])
}

컬렉션 - Slice

Go 의 배열은 고정된 배열 크기로, 배열 크기를 동적으로 증가시키거나 부분 배열을 추출하는 등의 기능이 없다. Slice 는 내부적으로 배열에 기초해 만들어 졌지만 배열의 이런 제약점을 넘어 편리하고 유용한 기능을 제공한다. 슬라이스는 배열과 달리 고정된 크기를 미리 지정하지 않을 수 있고, 차후 그 크기를 동적으로 변경할 수도 있고, 부분 배열을 추출할 수도 있다.

Slice 의 선언은 배열의 선언과 같이 var v []T 로 하는데 배열과 달리 크기는 지정하지 않는다. 예를 들어, 정수형 Slice 변수 a 는 var a []int 로 선언할 수 있다.

package main
import "fmt"

func main() {
  var a []int
  a = []int{1, 2, 3}

  a[1] = 10
  fmt.Println(a)
}

Go 에서 Slice 를 생성하는 또 다른 방법으로 Go 의 내장함수 make() 를 이용할 수 있다. make() 함수로 슬라이스를 생성하면, 개발자가 슬라이스의 길이와 용량을 임의로 지정할 수 있다. make() 함수의 첫 번째 Parameter 로 생성할 Slice Type 을 지정하고, 두 번째는 Length, 세 번째는 Capacity 를 지정하면 모든 요소가 Zero value 인 Slice 를 생성하게 된다. 이 때 Capacity 를 생략하면 Capacity 는 Length 와 같은 값을 갖게 된다. Slice 의 길이/용량은 내장함수 len(), cap() 을 써서 확인할 수 있다.

func main() {
  s := make([]int, 5, 10)
  println(len(s), cap(s))
}

Slice 에 별도의 길이와 용량을 지정하지 않으면, 기본적으로 길이와 용량이 모두 0인 Slice 를 만드는데, 이를 Nil Slice 라 하고, nil 과 비교하면 True 를 리턴한다.

  • 부분 슬라이스 (Sub-Slice)

Slice 에서 일부를 추출해 Sub-Slice 를 만들 수 있다. Slice[FirstIdx : LastIdx] 형식으로 만드는데, Index 2부터 4까지 데이터를 갖는 Slice 를 만드려면, Slice[2:5] 와 같이 표현한다. 이 때 마지막 인덱스는 원하는 인덱스 + 1 을 사용한다. (Python 과 동일)

이 때, 인덱스는 모두 생략될 수 있다. FirstIdx 가 생략되면 0이, LastIdx 가 생략되면 해당 Slice 의 마지막 인덱스가 자동으로 대입된다.

package main
import "fmt"

func main() {
  s := []int{0, 1, 2, 3, 4, 5}
  s = s[2:5]

  fmt.Println(s)
}
  • Slice Append, Copy

Slice 에 새로운 요소를 추가하기 위해서는 Go 내장함수인 append() 를 사용한다. append() 의 첫 Paramter 는 Slice 객체이고, 두 번째는 추가할 요소의 값이다. 여러 값을 한번에 추가하기 위해서 두 번째 Parameter 뒤에 계속해 값을 추가할 수 있다.

func main() {
  s := []int{0, 1}

  s = append(s, 2)
  s = append(s, 3, 4, 5)

  fmt.Println(s)
}

append() 로 Slice 에 데이터를 추가할 때, Slice 의 Capacity 가 남은 경우 그 내에서 Length 를 변경해 데이터를 추가하고, Capacity 를 초과하는 경우 현재 용량의 2배에 해당하는 새로운 Underlying Array 를 생성하고 기존 값들을 모두 새 배열에 복제한 후 다시 Slice 를 할당한다.

package main
import "fmt"

func main() {
  sliceA := make([]int, 0, 3)

  for i := 1; i <= 15; i++ {
    sliceA = append(sliceA, i)

    fmt.Println(len(sliceA), cap(sliceA))
  }

  fmt.Println(sliceA)
}

두 Slice 를 합치기 위해서는 아래 예제와 같이 append() 를 사용한다. 이 때 주의할 점은 함수의 Parameter 로 주어지는 두 번째 Slice 뒤에 ... 를 붙인다는 것인데, 이 Ellipsis(...) 는 해당 Slice 의 컬렉션을 표현하는 것으로 두 번째 Slice 의 모든 요소들의 집합을 나타낸다. 즉, 아래 예제에서 sliceB...4, 5, 6 으로 치환된다고 볼 수 있다.

package main
import "fmt"

func main() {
  sliceA := []int{1, 2, 3}
  sliceB := []int{4, 5, 6}

  sliceC := append(sliceA, sliceB...)

  fmt.Println(sliceC)
}

이러한 확장 기능과 더불어, Slice 는 내장함수 copy() 를 사용해 한 Slice 를 다른 Slice 로 복사할 수 있다. copy(dst, src) 아래에서 설명하듯 Slice 는 실제 배열을 가리키는 포인터 정보만을 가지므로, 복사를 정확히 표현하면 Source Slice 가 갖는 배열의 데이터를 Target Slice 가 갖는 배열로 복제하는 것이다.

func main() {
	source := []int{0, 1, 2}
	target := make([]int, len(source), cap(source) * 2)
	copy(target, source)

	fmt.Println(target)
	println(len(target), cap(target))
}
  • Slice 의 내부구조

Slice 는 내부적으로 사용하는 배열의 부분 영역인 Segment 에 대한 메타 정보를 갖는다. Slice 는 크게 3개의 필드로 구성되는데, 첫번째 필드는 내부적으로 사용하는 배열에 대한 포인터이고, 두 번째는 세그먼트의 길이, 세번째는 세그먼트의 용량이다.

처음 Slice 가 생성될 때, 만약 길이와 용량이 지정되었다면, 내부적으로 Capacity 만큼의 배열을 생성하고, Slice 의 첫 번째 필드에 해당 배열의 Memory 위치를 지정한다. 그리고, 두 번째 길이 필드는 지정된 길이를 갖게되고, 세 번째 필드는 전체 배열의 크기를 갖는다.

만약, Slice 의 부분 Slice 를 생성하면 이 부분 Slice 는 Slice 의 데이터를 복사한 Slice 가 될까? 아니면 기존 Slice 가 내부적으로 사용하는 배열을 Pointing 하는 새로운 Slice 가 될까? 답은 아래 예제를 실행한 결과에서 볼 수 있다.

package main

import "fmt"

func main() {
	source := []int{0, 1, 2, 3, 4, 5}
	sub := source[2:5]
	fmt.Println(sub) // 2, 3, 4 출력

	source[3] = 10
	fmt.Println(sub) // 2, ?, 4 출력
}

Reference : 예제로 배우는 Go 프로그래밍