Learning Go 정리 2일차 - 기본 타입, 복합 타입

0

Learning Go

목록 보기
2/12

기본 데이터 타입과 선언

go는 여러 내장타입(built-in-type)으로 boolean, interger, float, string이 있다.

리터럴(literal)

go에서의 리터럴은 숫자, 문자 혹은 문자열을 쓰는 것을 나타낸다.

정수(integer) 리터럴은 일련의 숫자이다. 기본적으로 10진수를 사용하여 접두사를 이용하여 진법을 변경할 수 있다.

진법접두사
2진수0b
8진수0o
16진수0x

긴 정수 리터럴을 더 읽기 쉽게하기 위해서 go는 정수 리터럴 사이에 밑줄을 넣는 것을 허용한다. 가령 10진수를 천의 단위로 그룹을 구분하기 위해 1_234와 같이 쓸 수 있다. 이진수, 8진수, 16진수, 1,2,4바이트 경계로 나누어 표시하여 가독성을 높이는 용도로 사용할 수 있다.

부동소수점 리터럴은 값에서 소수부를 구분하는 소수점이 있다.

룬(rune) 리터럴은 문자를 나타내며 작은 따옴표로 묶어 사용한다. 따라서, go는 작은 따옴표와 큰 따옴표를 혼용하여 사용할 수 없다.

룬 리터럴은 단일 유니코드 문자('a'), 8비트 8진 숫자('\141'), 8비트 16진수 숫자('\x61'), 16비트 16진수 숫자('\u0061') 혹은 32비트 유니코드 ('\U00000061')로 쓸 수 있다. 역슬래시 특수 룬 리터럴 중 가장 유용하게 사용되는 줄바꿈('\n'), 탭('\t') 등이 있다.

문자 리터럴을 표시하는 방법은 두 가지가 있다. 대부분 해석된 문자열 리터럴(interpreted string literal)로 큰 따옴표를 사용해 만들 수 있다. ("Greetings and Salutations") 두번째 방법은 로우 문자열 리터럴(raw string)으로 역따옴포 "``"를 사용하면 된다. raw 문자열 리터럴을 사용하면 역따옴표 자체를 제외하고 모든 리터럴을 표함할 수 있다. 즉, 특수문자를 굳이 사용하지 않아도 줄바꿈이 된다.

`Greetings and
"Salutations"

만약 서로 다른 크기로 선언된 두 개의 정수를 더할 수 없다. 단, 부동소수점 표현에서 정수 리터럴을 사용하거나 부동소수점 변수에 정수 리터럴을 할당하여 사용할 수 있게는 해준다. 이는 go의 리터럴에서 타입이 지정되지 않았기 때문이다.

boolean

bool타입은 불리언 변수로 bool타입의 변수는 두 값 중에 하나를 가지는 데 true, false이다. bool의 zero value는 false이다.

숫자타입

go는 숫자타입이 크게 3가지 범주이고 총 12개가 있다. 3가지 범주는 정수타입, 부동소수점, 복소수이다.

정수타입

go는 1~4바이트까지 다양한 크기를 가지는 부호있는 정수와 부호 없는 정수를 모두 제공한다.

int8, int16,int32, int64, uint8, uint16, uint32, uint64 등이 있다. 모든 정수의 zero value는 이다.

go의 정수 타입을 위한 몇 가지 특수한 이름들이 있다. byte타입은 uint8의 alias이다. 따라서 byte와 uint8은 서로 호환가능하다.

두 번째 특수한 이름은 int이다. 32bit CPU에서는 int32가 되고, 64bit CPU에서는 int64가 된다. 플랫폼마다 타입이 달라질 수 있기 때문에 intint32int64를 서로 호환하지 않도록 하자.

참고로 정수 리터럴은 기본적으로 int타입이다.

세 번째 특수이름은 uint이다 int와 동일한 규칙이며 단지 부호가 없을 뿐이다.

정수 타입에 또 다른 2개의 특수한 이름이 있는데, runeuintptr이다. 이들은 뒤에 설명하도록 하자.

정수타입들은 정수 연산자를 이용하여 산술 연산이 가능하다. +, -, *, /, %를 이용한 결과가 가능하며, 나눗셈을 사용하면 0의 자리 아래의 소수는 모두 버리는 내림연산을 한다. 만약 정수타입을 나누어 부동소수점 결과를 얻고 싶다면 정수를 부동소수점으로 타입 변환하여 사용하면 된다.

모든 산술 연산자들은 =과 함께 쓰이는 +=, -=, *=, /=, %=가 가능하다. 정수 비교를 하기위해서는 ==, !=, >, >=, <, <=이 있다.

또한, 정수를 위한 비트 조작 연산자를 가지고 있따. <<, >>를 이요하여 비트를 왼쪽이나 오른쪽으로 shift할 수 있고 &(AND), |(OR), ^(NOT)을 사용할 수 있으며 &^를 함께 사용하여 비트마스킹도 할 수 있다. 산술 연산자와 동일하게 =와도 같이 사용할 수 있다. &=, |=, ^=, &^=, <<=, >>=이 가능하다.

부동소수 타입

go는 float32, float64와 같은 부동소수점 타입을 지원한다. zero value는 0이다. 부동소수 리터럴은 float64를 사용하여 더 넓은 범위의 값을 표현할 수 있다. float32는 소수점 아래 6~7자리까지의 정밀도를 가지기 때문에 float64를 사용하는 것이 더 좋다. go의 부동소수점은 IEEE754를 따르기 때문에 64개의 비트 중 하나는 부호 비트, 11개의 비트는 밑이 2인 지수를 그리고 52비트는 정규화된 가수를 표현한다.

부동소수점은 %연산자를 제외하고 모든 수학 연산과 비교 연산을 사용할 수 있다. 0이 아닌 부동소수 변수를 0으로 나누면 원래 숫자의 부호에 따라 +Inf, -Inf가 나온다. 0으로 설정된 부동 소수점 변수를 0으로 나누면 NaN(not a number)가 나온다. 참고로 정수를 0으로 나누면 panic이 발생한다.

부동소수점을 ==!=을 사용하여 동등 비교연산을 할 수 있지만, 최대한 하지 않는 것이 좋다. 부동소수점은 일반적으로 부정확한 값을 가지므로, 두 값이 같다고 생각해도 같지않을 수 있다. 대신에 최소 허용 분산을 정의하고 두 변수가 간에 차이가 그 보다 작은 값인지를 확인하여 동등비교를 할 수 있다.

복소수타입

go는 복소수 숫자 타입으로 complex64complex128을 지원한다. complex64float32값을 사용하여 실수부와 허수부를 표현하고 complex128float64를 사용하여 실수부와 허수부를 표현한다.

모든 표준 산술 연산자는 복소수 숫자에도 적용이 가능하다. math/cmplx 패키지를 통해 복소수 타입들의 조작도 가능하다.

문자열과 룬

go는 내장 타입으로 문자열을 지원한다. zero value는 빈 문자열("")이다. go는 유니코드를 지원하기 때문에 문자열 리터럴에 유니코드를 넣을 수 있다.

문자열도 ==, !=을 사용하여 동등 비교를 할 수 있다. 또한, >, >=, <=을 사용하여 문자열의 순서도 사용할 수 있다. 추가적으로 +연산자를 사용하여 문자열을 연결할 수도 있다.

go에서 문자열의 속성은 불변이다. 따라서 문자열 변수에 값을 재할당할 수 있지만, 할당되어 있는 값을 변경할 수는 없다.

또한 go는 단일 코드 포인트를 표현하기 위한 타입도 가지고 있는데 rune타입은 int32의 별칭이다. 룬 리터럴의 기본 타입은 룬이고 문자열의 기본 타입은 문자열이다. 문자열과 문자에 대해서는 추후에 더 자세히 알아보도록 하자.

명시적 타입 변환

변수 타입이 서로 맞지 않을 때 명시적으로 타입 변환을 해주어야 한다. 다른 크기를 가지는 정수나 부동소수점 조차도 상호작용을 위해 동일한 타입으로 변환해야 한다. 이렇게 하면 모든 타입 변환 규칙을 기억할 필요없이 원하는 타입을 명확하게 만들 수 있다.

  • 타입 변환
var x int = 10
var y float64 = 30.2
var z float64 = float64(x) + y
var d int = x + int(y)

변수 4개를 선언하였는데, 연산을 위해서는 타입을 서로 변환해야했다. 명시적으로 타입을 변환하기 위해서는 float64()과 같이 Type()을 사용하면 된다.

go에서는 암묵적으로 타입을 변환하는 문법이 없다. 모든 타입 변환을 명시적으로 해야한다. 다른 go타입을 bool로 취급할 수가 없다. 즉, 0 이나 문자열이 비어있거나하면 대부분의 언어들은 false로 표현한다. 그러나 go는 그렇지 않다. true의 값으로 변환하는 것도 마찬가지이다. 즉, 어떠한 타입도 명시적으로든 암묵적으로든 bool로 변경할 수 없다.

만약 특정 타입을 bool로 변경하고 싶다면 비교연산자를 사용하는 것이 좋다. 가령 정수 변수인 x0인지 아닌 지를 bool로 표현하고 싶다면 x == 0으로 쓰면된다. 비교연산자를 사용하면 반환값이 bool로 나오기 때문이다. 문자열이 비어있는 지를 확인하고 싶다면 s == ""를 쓰면 된다.

변수 선언방법

go에서는 변수를 선언하는 방법이 다양한데, var 키워드를 사용하는 방법이 있다.

var x int = 10 // 명시적으로 타입을 주어 변수 선언과 초기화
var x = 10 // 타입없이 변수 선언과 초기화
var x int // 변수 선언
var x ,y int = 10, 20 // 명시적으로 같은 타입의 변수 선언 및 초기화
var x, y int // 명시적으로 같은 타입의 변수 선언
var x, y = 10, "hello" // 다른 타입의 변수 선언

// 여러 변수들의 선언을 하나로 묶기
var ( 
    x int
    y = 20
    z int = 30
    d, e = 40, "hello"
    f, g string
)

// 단축형 표현으로 변수 선언과 초기화를 동시해 진행
x := 10
x, y := 10, "hello"

x := 10을 사용하면 x변수를 선언하고 10이라는 값으로 초기화한다. 즉, var x int = 10의 단축형이다.

추가적으로 :=연산자는 var로는 할 수 없는 한 가지 기능이 가능한데, 새로 선언하는 변수가 := 연산자 왼쪽에 하나 있으면, 다른 변수들은 이미 존재하는 변수라고 값이 할당되낟.

x := 10
x, y := 30, "hello"

만약 변수 y도 변수 x처럼 먼저 선언되었다면 x, y := 30, "hello"문법은 에러를 발생시킨다. 왜냐하면 변수 x,y 둘이 모두 선언되었기 때문에 단축형인 :=을 사용할 수 없기 때문이다. 하지만 위의 예제에서는 변수 x는 이미 선언되었지만, 변수 y는 선언되지 않았다. 따라서 x, y := 30, "hello"을 사용하면 변수 y를 선언과 동시에 초기화할 수 있으므로 := 문법을 허용한다.

단, :=에도 한 가지 제한이 있다. 패키지 레벨에서 변수를 선언하고자 하면 := 연산자의 사용은 함수 밖에서는 불가능하기 때문에 함수 밖인 전역변수에는 var 키워드를 사용해야한다.

:=은 조심해야할 것이 하나있는데 뒤에 나올 shadowing변수이다. :=는 이미 존재하는 변수와 새로운 변수에 모두 값을 할당하기 때문에 이미 있는 변수에 값을 할당하려고 의도했던 것이 새로운 변수를 만들게 되는 경우를 발생한다. 이는 뒤에 더 자세한 예제를 통해 알아보도록 하자.

const 사용

const는 다른 프로그래밍 언어들과 다를 바가 없다.

package main

import "fmt"

const x int64 = 10 // const는 var과 같이 상수 선언과 동시에 초기화가 가능하다.

// 상수 list를 만들어 여러 개의 상수들을 선언과 동시에 초기화할 수 있다.
const (
	idKey   = "id"
	nameKey = "name"
)
// 상수 선언에는 명시적으로 타입을 지어주지 않아도 된다.
const z = 20 * 10

func main() {
    // const는 패키지 레벨에서도 생성할 수 있고, 함수와 같은 지역 변수 스코프에서도 생성할 수 있다..
	const y = "hello"

	fmt.Println(x)
	fmt.Println(y)
    // const는 값을 변경하려고 하면 에러가 발생한다.
	x = x + 1
	y = "bye"

	fmt.Println(x)
	fmt.Println(y)
}

다음의 코드를 실행하면 에러가 발생할 것이다. 이는 const로 선언된 상수의 값을 변경하려고 시도하였기 때문이다.

const로 설정할 수 있는 값들은 다음과 같다.

  1. 숫자 리터럴
  2. true, false
  3. 문자열
  4. 룬 문자
  5. 내장 함수 complex, real, imag, len, cap
  6. 앞서 선언된 값과 연산자의 구성으로 된 표현

go에서는 배열과 슬라이스, 맵, 구조체 자체를 const로 만들 수 없다. 즉, 구조체의 항목을 변경 불가능하게 선언하는 방법도 없다. 함수 내에서 변수가 수정되고 있는 지는 명백하므로 불변성은 그리 중요치 않다는 것이다.

사용하지 않는 변수

go는 선언된 지역 변수들은 반드시 사용되어야 한다. 그러나 이는 그렇게 철저하진않다. 변수를 한번이라도 접근했거나 앞으로 접근될 일 없는 변수에 값을 쓴 경우에도 컴파일러는 오류가 없을 것이다.

func main() {
    x := 10
    x = 20
    fmt.Println(x)
    x = 30
}

go vet은 사용되지 않은 10과 30의 x 변수로 할당을 잡아내지 못하지만 golangci-lint는 잡아낼 수 있다. https://golangci-lint.run/usage/install

복합 타입

배열

배열의 선언 방식은 다음과 같다.

var x [3]int

위는 크기가 3인 정수 배열을 만든다. 값은 지정하지 않은 것이다. 배열의 위치는 []으로 접근이 가능하다.

배열 리터럴을 통해서 초기값을 넣어줄 수 있다.

var x = [3]int{1,5,10}

희소배열(대부분의 요소 값이 0으로 설정된 배열)을 만든다면 배열 리터럴 내에 지정된 인덱스 값만 설정할 수 있다.

var x = [12]int{1,5:4, 6:10, 100, 15}

결과는 다음과 같다. [1 0 0 0 0 4 10 100 15 0 0 0] 특정한 인덱스만 설정한 값이 들어가고 나머지는 0이 들어간다.

배열 리터럴을 이용해서 초기화를 할 때 배열의 개수를 넣지 않고 ...을 사용할 수도 있다.

var x = [...]int{10,20,30}

==을 통해서 배열 간의 비교도 가능하다.

var x = [...]int{1, 2, 3}
var y = [3]int{1, 2, 3}
fmt.Println(x == y) // true

배열에서 배열의 끝을 넘어서거나 음수의 인덱스를 사용하여 값을 읽거나 쓸 수 없다.

범위를 넘어서는 값을 가진 변수를 통해 배열을 읽거나 쓰기를 시도한다면 컴파일은 되지만 실행 중에 패닉이 발생한다

또한 go는 배열의 크기로 변수를 허용하지 않는다.

y := 10
var x [y]int{1,2,3,4,5,6,7,8,9}

다음과 같이 변수 y로 배열의 사이즈를 선언하면 에러가 발생한다. 이는, go에서 배열의 크기를 배열 타입의 일부로 간주하는 제한이 있기 때문이다. 즉, [3]int{}[4]int{}은 서로 다른 타입이다. 그런데 타입을 확인하는 것은 실행 중이 아니라 컴파일 단계에서 확인이 되어야 하므로, 배열의 타입 일부인 배열 사이즈를 변수를 사용해 할당해 줄 수 있지 않게되는 것이다.

추가적으로 동일한 타입을 가진 다른 크기의 배열 간에 타입 변환도 불가능하다. 크기가 다른 배열을 서로 변환을 할 수 없기 때문에 어떤 크기의 배열로도 실행 가능한 함수를 작성할 수 없다. 따라서 동일한 변수에 크기가 다른 배열을 할당할 수 없다.

때문에 배열을 사용하지 않는 경우가 훨씬 많다.

정리하자면, 배열은 길이를 타입의 일부로 사용하기 때문에 타입은 컴파일 단계에서 확인받아야 한다는 로직 때문에 동적인 사이즈로 배열을 선언할 수 없다.

슬라이스

슬라이스의 크기는 타입의 일부가 아니다. 따라서, 배열의 제약을 제거했기 떄문에 원하는 만큼 크기를 할당하고 늘리고, 줄 일 수 있다.

var x = []int{10,20,30}

사이즈를 지정하지 않으면 된다.

배열과 마찬가지로 특정 포지션만 값을 갖도록 희소 배열을 만들수도 있다.

var x = []int{1, 5: 4, 6, 10: 100, 15}
fmt.Println(x) // [1 0 0 0 0 4 6 0 0 0 100 15]

슬라이스를 그냥 선언만 해놓으면 zero값으로 nil이 들어간다.

var x []int
fmt.Println(x == nil) // true

단, 슬라이스는 비교가 불가능한 타입이다 두 슬라이를 두고 동일한 지 ==, !=을 사용하면 컴파일 오류가 발생한다. 오직 nil과의 비교만 가능하다.

append()함수를 사용하여 슬라이스에 새로운 요소를 추가할 수 있다.

var x []int
x = append(x, 10)

또한 한 번에 하나 이상의 값들을 추가할 수도 있다.

x = append(x, 1,5,2,32)

하나의 슬라이에 다른 슬라이스의 개별 요소들을 ...을 사용하여 추가적으로 확장할 수 있다.

x := []int{1}
y := []int{10, 20, 30}
x = append(x, y...)
fmt.Println(x) // [1 10 20 30]

append를 사용할 때 반환된 값을 원본 변수에 할당해주어야 반영이 된다. **go는 값에 의한 호출 방식(call by value)을 사용하기 때문에 append()로 전달된 슬라이스는 복사된 값이 함수로 전달된다. 이 함수는 복사된 슬라이스에 값들을 추가하고 추가된 복사본을 반환한다. 그렇기 때문에 함수 호출에 사용한 변수에 변환된 슬라이스를 다시 할당해 주어야 한다.

슬라이스는 일련의 값을 저장한다. 슬라이스의 각 요소는 연속적인 메모리 공간에 할당될 것이고, 이런 할당은 값을 빠르게 읽고 쓰기가 가능하도록 한다. 모든 슬라이스는 수용력(capability)을 가지는데 예약된 연속적인 메모리 공간의 크기 값을 가진다. 이 값인 len과 같거나 클 수 있다. 슬라이스에 값이 추가되면 길이가 1씩 증가한다. 길이(length)가 수용력(capability,cap)만큼 증가한다면 더 이상 넣을 공간이 없게 된다. 이 때 새로운 원소가 append된다면 go런타임을 사용하여 더 큰 수용력을 가지는 새로운 슬라이스를 할당해준다. 원본 슬라이스에 있던 값들은 새롭게 할당된 슬라이스로 복사되고, 새로운 값은 끝에 추가된다. 마지막으로 새로운 슬라이스를 반환한다.

즉, 원본 슬라이스의 원소 개수가 len이고 슬라이스가 가진 공간의 크기가 cap이라면 이들이 서로 같아지고 더 이상 새로운 원소를 넣을 공간이 없다면, 다른 메모리 공간에 새로운 슬라이스 변수를 선언하되 공간을 기존보다 더 크게해서 원본 슬라이스의 원소를 모두 복사한 다음, 복사본을 반환한다는 것이다.

때문에 append()을 사용하여 capability를 증가할 때 go런타임은 새로운 메모리를 할당하고 기존 데이터를 이전 메모리로부터 새로운 메모리로 복사를 하기 위한 시간이 요구된다. 또한, 이전에 사용된 메모리는 가비지 컬렉션에서 정리가 필요하다. 이러한 이유로 go런타임이 슬라이스의 capability이 다 차면 대게 기존 capability보다 두 배를 증가시킨다. 다만 go 1.14버전의 규칙은 capability가 1024보다 작은 경우네느 2배씩 확장하고 그 보다 큰 경우는 25%씩 확장한다.

내장 함수 len은 슬라이스에서 현재 사용 중인 길이를 반환하고, 내장 함수 cap 함수는 현재 슬라이스의 수용력을 반환한다. 물론 cap을 따로 선언하여 사용하는 경우는 거의 없다.

var x []int
fmt.Println(x, len(x), cap(x))
x = append(x, 10)
fmt.Println(x, len(x), cap(x))
x = append(x, 10)
fmt.Println(x, len(x), cap(x))
x = append(x, 10)
fmt.Println(x, len(x), cap(x))
x = append(x, 10)
fmt.Println(x, len(x), cap(x))
x = append(x, 10)
fmt.Println(x, len(x), cap(x))

다음 코드의 실행 결과는 다음과 같다.

[] 0 0
[10] 1 1
[10 10] 2 2
[10 10 10] 3 4
[10 10 10 10] 4 4
[10 10 10 10 10] 5 8

슬라이스에 얼마나 많은 요소를 넣을 것인지 확실한 계획이 있다면 알맞은 초기 수용력으로 슬라이를 만드는 것도 좋은 방법이다. 이를 위해 make함수를 사용하자.

make

슬라이스를 일반적으로 선언하는 방법으로는 미리 설정된 lengthcapability를 설정할 수가 없다. 이런 작업을 해주는 것이 make이다. make는 타입, 길이, 그리고 선택적으로 수용력을 지정하여 슬라이스를 만들 수 있다.

x := make([]int, 5)

이 코드는 길이가 5, 수용력이 5인 정수 슬라이스를 만든다. 길이가 5이기 때문에 x[0]에서 x[4]까지 접근이 가능한 요소이며 모두 0으로 초기화된다.

x := make([]int, 5)
x = append(x, 10)
fmt.Println(x, len(x), cap(x)) // [0 0 0 0 0 10] 6 10

길이가 5, 수용력이 5였던 x변수는 append()로 데이터를 추가하여 cap이 10이 되었다. 즉, 두배가 되었다는 것이다.

수용력과 길이를 따로 설정할 수도 있다.

x := make([]int, 5, 10) // [0 0 0 0 0]

다음의 코드는 길이가 5이고, 수용력이 10인 정수 슬라이스를 만든다.

조심해야할 것은 길이 0, 수용력이 0인 슬라이스도 만들 수 있는데, 그렇다해도 nil이 아니게 된다. make함수로 만든 slice는 nil이 아니기 때문이다.

x := make([]int, 0, 10)
fmt.Println(x == nil) // false

y := make([]int, 0, 0)
fmt.Println(y == nil) // false

var z []int
fmt.Println(z == nil) // true

주의할 것은 길이보다 작은 수용력을 할당하지 않도록 해야한다. 그렇게하는 것은 컴파일 과정에서 오류를 발생시킬 것이다. 변수를 사용해서 길이보다 작은 값의 수용력을 설정하도록 작성하면 나중에 패닉이 발생할 것이다.

슬라이스의 선언

슬라이스의 선언이 여러 개가 있었다. 어떤 방법을 사용해야할까?? 가장 중요한 목표는 슬라이스 내부적으로 확장되는 횟수를 최소화하는 것임을 알도록 하자.

만약, 슬라이스가 전혀 커질 일이 없다면 nil슬라이스를 만들기 위해 값의 할당이 없는 경우는 var을 사용하도록 하자.

  • var을 이용한 슬라이스 선언
var data []int

비어있는 슬라이스는 리터럴을 이용해서도 만들 수 있다.

  • 슬라이스 리터럴을 이용한 선언
var x = []int

이는 길이가 0이고, nil이 아닌 슬라이스를 만든다. 즉, x == nilfalse가 된다.

슬라이스에 시작 값을 가지거나 슬라이스 값이 변경되지 않는 경우라면 슬라이스 리터럴을 사용하여 선언하도록 하자.

  • 슬라이스 리터럴에 값을 초기화
data := []int{2,4,6,8}

슬라이스가 얼마나 커져야 하는지 잘 알고 있지만, 프로그램을 작성할 때 어떤 값인지 정확히 알 수 없다면 make을 사용해보자. 그러면 make 호출에 0이 아닌 길이를 지정해야하는 지 0 길이에 수용력을 0이 아닌 값을 지정해야할 지 생각해볼 수 있다. 이 때는 3가지 경우를 볼 수 있다.

  1. 버퍼로 슬라이스를 사용한다면 0이 아닌 길이로 지정하자. -> 이는 추후에 io 관련 기능을 배울 때 알아보도록 하자.
  2. 원하는 크기를 정확히 알고 있다면 슬라이스 길이와 인덱스를 지정하여 값을 설정할 수 있다.
  3. 이외의 상황에서는 0의 길이와 지정된 capability을 갖기위해 make함수를 사용하는 것이다. 이는 append을 사용해서 슬라이스에 값들을 추가할 수 있도록 한다. 만약 요소의 수가 적어졌다면 마지막에 불필요한 0의 값이 생성되지 않고, 요소의 수가 많아지더라도 패닉을 발생시키지 않을 것이다.

기본적으로 0의 길이로 슬라이스를 초기화하고 append을 사용하는 것이 좋다. 어떤 경우에는 느려질 수 있지만 코드의 버그를 줄 일 수 있다.

슬라이스 슬라이싱

:을 사용하여 시작과 마지막 offset을 줄 수 있다. 물론 일반적인 슬라이싱과 마찬가지로 마지막 인덱스는 제외한다. 또한, 한쪽을 생략하면 끝까지 간다고 가정한다.

x := []int{1, 2, 3, 4}
y := x[:2]
z := x[1:]
d := x[1:3]
e := x[:]

fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)
fmt.Println("d:", d)
fmt.Println("e:", e)

결과는 다음과 같다.

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

주의할 것은 슬라이스는 때로 저장 공간을 공유한다는 것이다. 즉, 슬라이스를 슬라이싱해서 가져왔을 때 실제 데이터의 복사본을 만들지 않고 메모리를 공유하여 하나의 메모리에 대한 두 개의 변수를 가지게 되는 것 뿐이다. 이는 슬라이스의 요소를 변경하면 요소를 공유하고 있던 모든 슬라이에 영향을 준다는 것이다. 값을 변경했을 대 어떤 일이 발생하는 지 확인해보도록 하자.

x := []int{1, 2, 3, 4}
y := x[:2]
z := x[1:]
x[1] = 20
y[0] = 10
z[1] = 30
fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)

결과는 다음과 같다.

x: [10 20 30 4]
y: [10 20]
z: [20 30 4]

서로가 서로에게 영향을 주고 있다는 경과를 얻을 수 있다.

거기다 슬라이스의 슬라이싱에 append을 함께 사용하면 혼란이 가중된다.

x := []int{1, 2, 3, 4}
y := x[:2]
fmt.Println(cap(x), cap(y)) // 4 4
y = append(y, 30)
fmt.Println("x:", x) // x: [1 2 30 4]
fmt.Println("y:", y) // y: [1 2 30]

다른 슬라이로부터 슬라이싱을 할 떄, 하위 슬라이스의 수용력은 원본 슬라이스의 수용력에서 하위 슬라이스의 시작 offset만큼 뺀 값이 설정된다. 이는 원본 슬라이스의 사용되지 않은 모든 수용력은 만들어진 모든 하위 슬라이스에 공유가 된다는 의미이다.

x에서 y슬라이스를 만들 때, 길이는 2로 설정했지만 수용력은 x와 동일한 4로 설정된다. 수용력이 4이기 때문에 y에 끝에 값을 추가하는 것은 x의 세번째 위치에 요소를 넣는다.

이런 결과는 매우 이상한 시나리오를 만들 수 있는데, 여러 슬라이에 값을 추가하는 것으로 다른 슬라이의 데이터를 덮어쓰기가 가능한 것이다.

x := make([]int, 0, 5)
x = append(x, 1, 2, 3, 4)
y := x[:2]
z := x[2:]
fmt.Println(cap(x), cap(y), cap(z)) // 5 5 3
y = append(y, 30, 40, 50)
x = append(x, 60)
z = append(z, 70)
fmt.Println("x:", x) // x: [1 2 30 40 70]
fmt.Println("y:", y) // y: [1 2 30 40 70]
fmt.Println("z:", z) // z: [30 40 70]

위의 코드를 보면 더욱 혼란이 가중된다.

따라서, 복잡한 슬라이스 상황이 발생하지 않도록 하기 위해, 하위 슬라이스에 append()을 사용하지 않거나 append을 사용해도 덮어쓰기가 되지 않도록하는 완전한 슬라이스 연산(full slice expression)을 사용하도록 하자. 완전 슬라이스 연산은 부모 슬라이스에서 파생된 하위 슬라이스에 얼마나 많은 메모리를 공유할 것인지 명확하게 해준다. 완전한 슬라이스 연산은 하위 슬라이스를 위한 가용한 부모 슬라이스의 수용력의 마지막 위치를 지정하는 세 번째 인자를 가진다. 하위 슬라이스의 수용력을 계산하기 위해서는 세번째 인자에서 시작 오프셋을 빼면 된다.

y := x[:2:2]
z := x[2:4:4]

위와 같이 변경하고 다시 프로그램을 구동하면 정상적인 결과를 얻게된다.

5 2 2
x: [1 2 3 4 60]
y: [1 2 30 40 50]
z: [3 4 70]

이것이 성공하게 된 가장 큰 이유는 하위 슬라이싱을 나눌 때 길이와 수용력을 동일하게 해주었기 때문이다. apppend()연산이 이루어지면서 새로운 메모리에 새로운 슬라이스 객체를 할당하고 거기에 원본 값을 채워넣는다고 했다. 즉, 원래 문제는 메모리 공유였기 때문에 이렇게 하위 슬라이스에서 새롭게 메모리를 할당하게되면 문제를 해결할 수 있게 되는 것이다.

배열을 슬라이스로 변환

배열도 슬라이싱이 가능하다. 배열에서 슬라이싱된 객체는 슬라이스이다. 이를 활용하여 배열을 슬라이스처럼 만들 수 있다. 즉, 어떤 함수의 매개변수가 슬라이스를 받고 있다면 배열을 슬라이싱하여 호환이 가능하다는 것이다. 다만, 배열도 마찬가지로 하위 슬라이스와 메모리를 공유한다.

x := [4]int{5, 6, 7, 8}
y := x[:2]
z := x[2:]
z = append(z, 10)
fmt.Println("x:", x) // x: [5 6 7 8]
fmt.Println("x:", y) // x: [5 6]
fmt.Println("x:", z) // x: [7 8 10]

copy

원본 슬라이스로부터 독립적인 슬라이스를 생성하고 싶다면 내장 함수인 copy를 사용하도록 하자.

x := []int{1, 2, 3, 4}
y := make([]int, 4)
num := copy(y, x)
fmt.Println(y, num) // 1 2 3 4] 4

copy함수는 2개의 파라미터를 가지는데, 첫번째는 대상 슬라이스이고, 두 번째는 파라미터 원본 슬라이스이다. 더 작은 슬라이스를 기준으로 원본 슬라이스에서 최대한 값을 복사할 것이고, 실제 복사된 요소의 개수를 반환할 것이다. x와 y의 수용력보다는 길이가 중요하다.

슬라이스의 슬라이싱을 이용하여 일부만 복사되도록 할수도 있다. 중요한 것은 길이이다.

x := []int{1, 2, 3, 4}
y := make([]int, 2)
z := make([]int, 2)
copy(y, x[2:])
copy(z, x[:2])
fmt.Println(y) // [3 4]
fmt.Println(z) // [1 2]

copy함수는 원본 슬라이스의 겹치는 영역을 가지는 두 개의 슬라이스 간의 복사도 가능하게 한다.

x := []int{1, 2, 3, 4}
num := copy(x[:3], x[1:])
fmt.Println(x, num) // [2 3 4 4] 3

위의 경우 x의 마지막 3개의 값들이 x의 맨 앞에 3개 요소들 위치에 복사가 된다. 해당 코드가 출력하는 값은 [2 3 4 4] 3이 될 것이다.

배열의 슬라이스를 취하는 방식으로 copy에 배열을 사용할 수도 있다. 배열을 copy함수의 원본 혹은 대상 인자로 사용할 수 있다.

x := []int{1, 2, 3, 4}
d := [4]int{5, 6, 7, 8}
y := make([]int, 2)
copy(y, d[:])
fmt.Println(y) // [5 6]]
copy(d[:], x)
fmt.Println(d) // [1 2 3 4]

문자열과 룬 그리고 바이트

go의 문자열은 룬으로 만들어진다고 생각할 수 있지만, 실제로는 그렇지 않다. 내부적으로느 go는 문자열을 표현하기 위해 일련의 바이트를 사용한다. 이 바이트는 어느 특정한 문자 인코딩을 가지진 않지만, 몇몇 go라이브러리 함수는 문자열이 UTF-8인코딩으로 구성되어 있다고 간주한다.

언어 스펙에 따르면 go소스코드는 항상 UTF-8로 쓰여진다. 문자열 리터럴에 16진수 이스케이프를 사용하지 않는다면, 문자열 리터럴은 UTF-8로 쓰여진다.

배열이나 슬라이스에서 단일 값을 추출하는 것과 같이 문자열도 인덱스 표현으로 단일 값을 꺼내올 수 있다.

var s string = "Hello there"
var b byte = s[6]

문자열에도 슬라이싱이 적용 가능하다.

var s string = "Hello there"
var s2 string = s[4:7]
var s3 string = s[:5]
var s4 string = s[6:]
fmt.Println(s2) // o t
fmt.Println(s3) // Hello
fmt.Println(s4) // there

그러나 이렇게 인덱스로 접근하고 슬라이싱을 하는 것은 조심해야하는데, 문자열은 일련의 바이트로 구성되기 때문이다. UTF-8코드는 1에서 4바이트로 어디든 위치할 수 있다. 위의 예제에서는 1byte길이의 UTF-8코드로만 구성을 했기 때문에 원하는 결과가 잘 나왔을 것이다. 하지만 영어가 아닌 다른 나라의 언어나 이모티콘을 처리하려 할 때 여러 바이트에 걸치 UTF-8의 코드를 사용해서 코드를 수행해야한다.

var s string = "Hello ◕‿◕"
var s2 string = s[4:7]
var s3 string = s[4:]
fmt.Println(s2) //o �
fmt.Println(s3) //o ◕‿◕

s2의 슬라이싱에서 이모티콘의 일부 바이트를 가져왔다. 이모티콘은 UTF-8에서 1byte를 넘기때문에 이상한 문자가 출력되는 것이다.

문자열을 len()함수에 넘겨 해당 문자열의 길이를 파악할 수 있다. 문자열 인덱스와 슬라이스 표현식이 위치를 바이트 단위로 계산한다는 것을 생각한다면 len을 통해 반환된 길이는 코드 단위가 아니라 바이트 단위라는 것을 알 수 있다.

var s string = "Hello ◕‿◕"
fmt.Println(len(s)) // 15

이모티콘에만 9byte가 쓰인 것이다.

따라서, 문자열의 데이터가 1byte짜리로만 올 경우만 인덱싱과 슬라이싱을 하도록 하자.

rune, 문자열, byte는 복잡한 관계를 가지기 때문에 go는 이러한 타입 간에 변환을 할 수 있도록하는 재밌는 기능들을 제공한다. 단일 룬 혹은 바이트는 문자열로 변환이 가능하다.

var a rune = 'x'
var s string = string(a)
var b byte = 'y'
var s2 string = string(b)

참고로 정수에 string을 씌운다고 정수 문자열로 바뀌지 않는다. UTF-8인코딩에 맞는 문자로 변경된다.

var x int = 65
var y = string(x)
fmt.Println(y) // 'A'

string은 바이트 슬라이스나 룬 슬라이스로 변환이 가능하다.

var s string = "Hello ◕‿◕"
var bs []byte = []byte(s)
var rs []rune = []rune(s)
fmt.Println(bs) // [72 101 108 108 111 32 226 151 149 226 128 191 226 151 149]
fmt.Println(rs) // [72 101 108 108 111 32 9685 8255 9685]

문자열을 UTF-8 []byte로 변환한 것과 문자열을 []rune으로 변환한 결과이다.

go에서 대부분의 데이터는 일련의 바이트로 읽거나 쓸 수 있기 때문에 대부분의 일반 문자열은 바이트 슬라이스 타입으로 변환이 가능하다. 룬 슬라이스 변환은 드물다.

문자열을 슬라이스와 인덱스 표현법으로 사용하기 보다는 표준 라이브러리인 stringsunicode/utf8패키지에 있는 함수를 사용하여 하위 문자열이나 코드 포인트를 추출하여 사용하도록 하자.

map(맵)

맵은 map[keyType]valueType형태로 선언된다. 맵을 선언할 수 있는 몇 가지 방벙이 있다.

다음은 var키워드를 사용하여 맵변수를 생성하고 제로 값을 할당할 수 있다.

var nilMap map[string]int

이렇게 하면 nilMap이 문자열 타입의 키와 정수를 값으로 가지는 맵으로 선언된다. 맵의 zero value는 nil로 길이가 0이다. nil맵의 값을 읽으려고 하면 맵 값이 되는 타입의 제로 값이 반환된다. 하지만 nil맵에 값을 쓰려고 하면 panic이 발생한다.

:=연산자를 사용하여 맵 변수를 선언하고 맵 리터럴을 할당할 수 있다.

totalWins := map[string]int{}

이렇게하면 비어 있는 맵 리터럴을 사용하게 된다. 이것은 nil맵과 다르다. 길이가 0이지만 비어있는 맵 리터럴이 할당된 맵을 읽고 쓸 수 있다. 비어 있지 않은 맵 리터럴은 다음과 같다.

teams := map[string][]string{
    {"Orcas": []string{"Fred", "Ralph", "Bijou"}},
    {"Liones": []string{"Sarah", "Peter", "Bille"}},
}

맵에서의 value은 어떠한 타입이든 상관없지만, key는 몇 가지 제약이 있다.

만약, 키-쌍이 얼마나 들어갈지는 알고 있지만, 정확히 어떤 값이 들어갈지 모른다면 make를 이용하여 기본 크기를 지정해 맵을 생성할 수 있다.

ages := make(map[int][]string, 10)

make로 생성된 맵은 길이가 0이고, 초기에 지정한 크기 이상으로 커질 수 있다.

맵은 여러가지 방법적인 면에서 슬라이스와 같은 부분이 있다.
1. 맵은 키-값 쌍이 추가되면 자동으로 커진다.
2. 맵에 넣을 키-값 쌍의 데이터가 어느정도 되는 지 파악이 된다면, make를 통해 특정한 크기로 초기화하여 생성할 수 있다.
3. len함수에 맵을 넘긴다면 키-쌍이 맵에 몇개가 있는 지 알려준다.
4. 맵의 제로값은 nil이다.
5. 맵은 비교 불가능하다. nil과 같은지는 비교가 가능하지만, 두 개의 맵에 키와 대응되는 값이 동일하게 들어있는 지를 비교하기위해 ==, !=은 사용할 수 없다.

맵의 키는 모든 비교 가능한 타입이 될 수 있다. 이것은 맵의 키로써 slice, map이 될 수 없다는 것을 의미한다.

맵은 엄격하게 증가하는 순서가 아닌 값들을 구성하는 데이터가 있을 사용하면 좋다. 즉, 맵을 사용할 때는 요소의 순서는 상관이 없다.

아직 설정하지 않은 맵 키에 할당된 값을 읽으려고 시도할 때 맵의 값이 되는 타입의 제로 값을 반환한다.

comma 관용 OK관용구가 있는데, 맵은 키에 대응되는 값이 없어도 기본 제로 값을 반환한다. 하지만, 때로는 맵에 키가 있는 지 확인해야 하는 경우도 있다. go는 comma ok idiom로 맵에 키가 없어 제로 값을 반환하는 경우와 키에 해당하는 값으로 0을 반환한 것 인지를 구분하여 알려줄 수 있다.

m := map[string]int{
    "hello":5,
    "world":0
}
v, ok := m["hello"]
fmt.Println(ok) // true

v, ok := m["goodbyt"]
fmt.Println(ok) // false

두번쨰 반환되는 값은 boolok라는 변수로 할당받았다. true라면 해당 키에 값이 있다는 것이고, false라면 해당 키에 값이 없다는 것이다.

맵의 키-값 쌍을 제거하고 싶다면 delete를 사용하면 된다.

m := map[string]int{
    "hello":5,
    "world":0
}
delete(m, "hello")

delete함수는 맵과 키를 받아 키에 해당하는 키-쌍을 제거한다. 키가 맵에 존재하지 않거나 맵이 nil이어도 어떤 일도 발생하지 않는다. delete함수는 반환 값이 없다.

맵을 set으로 이용

set은 중복을 허용하지 않은 집합과 같은 개념이다. 때문에 순서도 보장하지 않는다. go는 set을 따로 지원하지는 않지만 map을 이용하여 set처럼 만들 수 있다. set에 넣고자 하는 타입은 map의 키로하고, 값으로는 bool로 설정한다.

intSet := map[int]bool{}
vals := []int{5, 10, 2, 5, 8, 7, 3, 9, 1, 2, 10}
for _, v := range vals {
    intSet[v] = true
}
fmt.Println(vals, len(intSet)) // [5 10 2 5 8 7 3 9 1 2 10] 8
fmt.Println(intSet[5])         // true
fmt.Println(intSet[500])       // false
if intSet[100] {
    fmt.Println("100 is in the set")
}

이제 intSet변수에 11개의 값이 쓰여졌으나 해당 변수의 길이는 8이 된다. 이유는 map에서 중복 키를 허용하지 않기 때문이다. intSet에서 5의 값을 찾을 때, 해당 변수에 5라는 키값을 가지고 있기 때문에 true가 반환된다. 하지만 500, 100의 값을 찾으면 false가 반환 될 것이다.

set으로 합집합, 교집합, 차집합과 같은 집합 연산을 하고 싶다면, 서드 파티를 이용하거나 직접 만들도록 하자.

구조체

여러 데이터 타입을 함께 구성하고자 할 때는 struct를 정의하여 사용하도록 하자.

type person struct {
    name string
    age int 
    pet string
}

type키워드로 구조체 타입의 이름을 지정하고, 키워드 struct 다음 중괄호 구조체를 정의할 수 있다. 중괄호 내에 구조체가 포함할 항목들을 나열하기만 하면 된다. 기술적으로 구조체는 어떠한 블럭에서도 정의가 가능하다. 다만, 정의된 블록 이외에는 사용할 수 없다.

일단 구조체 타입이 선언되면 해당 타입으로 변수를 선언할 수 있다.

var fred person

여기서는 var선언을 사용하게 된다. fred는 어떠한 값도 할당하지 않았기 때문에 person구조체 타입을 위한 zero value로 설정된다. 구조체의 zero value는 가지는 모든 항목이 각각 제로 값으로 설정되는 것이다.

구조체 리터럴을 사용해서 변수에 할당할 수도 있다.

bob := person{}

맵과 다르게 어떠한 값도 할당하지 않는 경우와 비어 있는 구조체 리터럴을 할당하는 것 사이에는 차이점이 없다. 두 가지 경우 모두 구조체 내에 존재하는 모든 항목들이 각 타입에 맞는 제로 값으로 설정된다. 비어 있지 않은 구조체 리터럴을 위한 두 가지 선언 스타일이 있다. 구조체 리터럴은 콤마로 구분하여 중괄호 내에 각 항목에 대한 값들을 나열함으로써 지정될 수 있다.

julia := person{
    "julia",
    40,
    "cat",
}

위와 같은 구조체 리터럴 포맷을 사용할 때는 구조체의 모든 항목에 대응되는 값을 지정해주어야 하며, 각 값들은 구조체 내에 선언했던 항목 순서대로 할당이 이루어진다.

두 번째 구조체 리터럴 선언 방식은 map리터럴 선언과 비슷하다.

beth := person{
    age: 30,
    name: "Beth",
}

위와 같이 구조체 내에 항목 이름을 명시하여 값을 할당할 수 있다. 이런 방식을 사용할 때 특정 항목을 빼도 순서와 상관없이 항목의 값을 넣을 수 있다. 값이 지정되지 않은 변수는 제로 값으로 설정될 것이다.

변수를 구조체 타입 이름을 지정하지 않고 구조체 타입을 구현하여 선언할 수 있다. 이것을 anonymous struct 즉, 익명 구조체라고 한다.

var person struct {
    name string
    age int
    pet string
}

person.name = "bob"
person.age = 50
person.pet = "dog"

pet := struct {
    name stirng
    kind string
}{
    name: "Fibo",
    kind: "dog",
}

변수 personpet의 타입은 익명 구조체이다. 익명 구조체 내에 있는 항목에 값을 할당하기 위해 이름이 있는 구조체에서 한 방식대로 진행하면 된다. 구조체 리터럴을 사용해서 이름있는 구조체를 초기화 했 듯이 익명 구조체에도 동일하게 진행이 가능하다.

이런 익명 구조체가 사용되면 좋은 일반적인 두 가지 상황이 있다. 첫번째는 외부 데이터를 구조체로 전환할 때이고 두 번째는 구조체를 외부 데이터(JSON이나 프로토콜 버퍼)로 전환할 떄이다. 이런 전환을 마샬링, 언마샬링이라고 부른다. 이는 추후에 배워보도록 하자.

테스트 코드를 작성할 때도 익명 구조체가 잘 사용된다. 추후에 테이블 기반 테스트에서 익명 구조체의 슬라이스를 사용해보도록 하자.

구조체 비교와 변환

구조체가 비교 가능한 지 여부는 구조체의 항목에 따라 다르다. 모든 구조체 내의 항목이 비교 가능한 타입으로 구성되어 있다면 비교 가능하지만, 슬라이스나 맵의 항목이 있다면 그렇지 않을 것이다. go에서 다른 기본 타입의 변수들 간의 비교를 하용하지 않는 것처럼, 다른 타입 구성의 주고체를 대변하는 변수들 간의 비교도 하용하지 않는다. go는 두 개의 구조체가 같은 이름, 순서, 타입으로 구성되어 있다면, 구조체 간에 타입 변환을 수행할 수 있도록 한다. 예제를 확인하자.

type firstPerson struct {
	name string
	age  int
}

type secondPerson struct {
	name string
	age  int
}

type thirdPerson struct {
	age  int
	name string
}

type fourthPerson struct {
	name1 string
	age   int
}

type fifithPerson struct {
	name   string
	age    int
	height int
}

func main() {
	var p firstPerson
	p.age = 12
	p.name = "name"

	fmt.Println(p) // {name 12}
	s := secondPerson(p)
	fmt.Println(s)                    // {name 12}
	fmt.Println(s == secondPerson(p)) // true
	// t := thirdPerson(s)
	// f := fourthPerson(s)
	// f := fifithPerson(s)
}

위와 같이 구조체 타입이 달라도, 그 맴버 변수들의 순서와 이름, 타입이 같으면 변환이 가능하며 비교도 가능하다. 다만 맴버 변수의 순서, 타입, 개수 등이 하나라도 다르면 변환이 불가능하다.

다만, 맴버 변수들의 순서와 이름, 타입이 같으면 변환이 가능하다했지 할당이 가능한 것은 아니다. 즉 s = p와 같은 것이 안된다.

익명 구조체는 이런 상황에서 조금 다른 경우를 제공하는데, 두 구조체 변수가 비교 가능하고 이중 하나는 익명 구조체이면서 두 구조체 다 같은 이름, 순서, 타입을 가진다면 타입 변환없이 서로 비교가 가능하다. 또한, 구조체의 맴버 변수가 이름, 순서, 타입이 모두 같다면, 이름이 있는 구조체와 익명 구조체 간에 할당도 가능하다. 즉, 익명 구조체는 구조체끼리의 이름, 타입, 순서가 같으면 할당도 가능하다는 것이다.

type firstPerson struct {
	name string
	age  int
}

func main() {
	f := firstPerson{
		name: "Bob",
		age:  50,
	}

	var g struct {
		name string
		age  int
	}

	g = f
	fmt.Println(f == g) // true
	fmt.Println(g)      // {Bob 50}
}

이름이 있는 구조체인 firstPerson를 가진 f와 이와 순서, 타입, 이름이 모두 같은 익명 구조체를 가진 g 변수는 서로 호환이 가능하다. 즉, 할당도 되고 비교도 된다.

0개의 댓글