본문 바로가기

Go 언어 공부

[GO 마스터하기] 03-Go언어의 기본 데이터 타입

Go 언어의 루프

Go 언어에서는 for 루프를 사용해 순환 반복 구문을 프로그래밍할 수 있다.

for 루프

for i := 0; i < 100; i++ {
}

이 루프문을 보면, i는 0부터 99까지의 값을 받는다. 여기서 i는 지역 변수이자 임시 변수로서, for 루프가 종료된 후에는 일정한 시점에 가비지 컬렉션되어 사라진다. 하지만 i를 for 루프 밖에 정의하였다면 for 뤂프가 종료된 후에도 값을 그대로 유지한다. 위의 코드에서는 루프가 종료 후 i는 100이된다.

while 루프

Go 언어는 while 키워드를 직접 제공하지 않으나 for 루프를 이용해 while 루프를 작성할 수 있다.

for {
}

do while문도 표현이 가능하다

for ok := true; ok; ok = 표현식 {
}

ok가 false가 되는 순간 루프는 종료된다

range 키워드

range 키워드의 가장 큰 장점은 슬라이스나 맵의 크기를 몰라도 그 안에 들어 있는 원소를 하나씩 처리할 수 있다는 것이다.

package main

import "fmt"

func main() {
    i := 0
    anExpression := true
    for ok := true; ok; ok = anExpression {
        if i > 10 {
            anExpression = false
        }
        fmt.Print(i, " ")
        i++
    }
    fmt.Println()

    anArray := [5]int{0, 1, -1, 2, -2}
    for i, value := range anArray {
        fmt.Println("index :", i, "value :", value)
    }
}
$ go run foo.go
0 1 2 3 4 5 6 7 8 9 10 11 
index : 0 value : 0
index : 1 value : 1
index : 2 value : -1
index : 3 value : 2
index : 4 value : -2

Go 에서 배열

배열이 가장 인기 있는 데이터 구조인 이뉴는 간결하고 이해하기 쉽고, 다양한 종류의 데이터를 저장할 수 있기 때문이다.

anArray := [4]int{1, 2, 4, -4}

먼저 배열의 크기를 적은 다음, 타입을 명시하고, 마지막에 원소를 나열한다.

다차원 배열

twoD := [4][4]int{ {1,2,3,4}, {5,6,7,8}}

Go 배열의 단점

  1. 배열을 정의한 후에는 크기를 변경할 수 없다. 즉 Go에서의 배열은 동적이지 않다.
  2. 함수의 매개변수로 배열을 전달할 때 내부적으로는 그 배열의 복사본이 전달된다.
  3. 크기가 큰 배열을 함수에 전닿하면 속도가 상당히 느려진다. → 복사해서 생성하기 때문

Go Sllice

슬라이스를 함수로 전달할 때 참조값을 전달한다. 즉 변수에 대한 메모리 주소가 전달된다는 뜻이다. 따라서 슬라이스 크기가 크더라도 함수로 전달할 때도 속도가 변하지 않는다.

aSliceLiteral := []int{1,2,3,4,5}

Slice Literal을 정의하는 방법은 원소의 수를 지정하는 부분만 없을 뿐, 배열과 거의 같다.

interger := make([]int, 20)

또한 make함수를 통해 길이와 용량을 매개변수로 전달하면 빈 슬라이스를 생성할 수 있다. 용량은 생략이 가능한데 그러면 용량은 길이와 같게 된다.

주목할 점은 지정한 타입에서 0에 해당하는 값으로 슬라이스를 초기화하는 것이다.

interget = nil 

nil을 대입하여 내용을 지울 수 있다.

interger = append(interger, -50000)

append() 함수를 이용해 슬라이스에 원소를 추가할 수 있다. 크기가 자동으로 증가한다.

interger[1:3]

[:] 문법으로 슬라이스에 연속으로 나와 있는 원소들을 접근할 수 있다.

s2 := interger[1:3]

이런식으로 새로운 슬라이스를 생성할 때도 사용할 수 있으나 경우에 따라 문제를 야기한다.

package main

import "fmt"

func main() {

    s1 := make([]int, 5)
    reSlice := s1[1:3]
    fmt.Println(s1)
    fmt.Println(reSlice)

    reSlice[0] = -100
    reSlice[1] = 123458

    fmt.Println(s1)
    fmt.Println(reSlice)
}
$ go run foo.go
[0 0 0 0 0]
[0 0]
[0 -100 123458 0 0]
[-100 123458]

리슬라이싱한 슬라의스의 원소를 변경하면 원본 슬라이스의 원소도 바뀌는것을 명심하자. 이는 두 슬라이스가 동일한 메모리 주소를 가리키고 있기 때문이다. 정리하면 리슬라이싱은 원본 슬라이스에 복사본을 만드는 것이 아니다.

또한 어떤 슬라이스의 아주 작은 부분만 리슬라이싱하더라도 원본 슬라이스에 대한 내부 배열은 리슬라이싱한 부분이 존재하는한 메모리에 계속 남아 있게 된다는 점이다. 리슬라이싱한 슬라이스에서 참조해야하기 때문이다.

슬라이스는 자동으로 확장된다

슬라이스는 용량, 길이를 가진다. 슬라이스의 길이는 배열의 길이와 같으면 len()으로 알아낼 수 있다. 반면 슬라이스의 용량은 이 슬라이스에 할당된 공간을 의미하며 cap()으로 알아낼 수 있다. 슬라이스의 용량은 동적으로 변한다. 슬라이스에 공간이 부족하면 Go 언어는 현재 길이를 자동으로 두 배로 확장해 더 많은 원소를 담을 공간을 확보한다.

간단히 말해 슬라이스에서 길이와 용량이 같을 때 다른 원소를 추가하면 슬라이스의 용량은 두 배가 되는 반면, 길이는 1만큼 증가한다.

package main

import "fmt"

func printSlice(x []int) {
    for _, number := range x {
        fmt.Print(number, " ")
    }
    fmt.Println()
}

func main() {

    aSlice := []int{-1,0,4}
    fmt.Printf("aSlice: ")
    printSlice(aSlice)

    fmt.Printf("Cap : %d, Length : %d\n", cap(aSlice), len(aSlice))
    aSlice = append(aSlice, -100)
    fmt.Printf("aSlice: ")
    printSlice(aSlice)
    fmt.Printf("Cap : %d, Length : %d\n", cap(aSlice), len(aSlice))

    aSlice = append(aSlice, -2)
    aSlice = append(aSlice, -3)
    aSlice = append(aSlice, -4)
    printSlice(aSlice)
    fmt.Printf("Cap : %d, Length : %d\n", cap(aSlice), len(aSlice))
}
$ go run foo.go
aSlice: -1 0 4 
Cap : 3, Length : 3
aSlice: -1 0 4 -100 
Cap : 6, Length : 4
-1 0 4 -100 -2 -3 -4 
Cap : 12, Length : 7

cap의 크기는 2배로 늘어나는 것을 확인할 수 있다.

copy() 함수

copy 함수를 이용하면 기존 배열의 원소로부터 슬라이스를 생성할 수도 기존 슬라이스에서 다른 슬라이스로 복사할 수도 있다. 하지만 copy() 함수는 조심해서 사용해야 한다.

  • 기본 제공되는 copy(dest, src)함수는 len(dst)와 len(src)의 최소값에 해당하는 수만큼의 원소만 복사하기 때문이다
package main

import "fmt"

func main() {

    a6 := []int{-10, 1, 2, 3, 4, 5}
    a4 := []int{-1, -2, -3, -4}
    fmt.Println("a6:", a6)
    fmt.Println("a4:", a4)

    copy(a6, a4)
    fmt.Println("a6:", a6)
    fmt.Println("a4:", a4)

    b6 := []int{-10, 1, 2, 3, 4, 5}
    b4 := []int{-1, -2, -3, -4}
    fmt.Println("b6:", b6)
    fmt.Println("b4:", b4)
    copy(b4, b6)
    fmt.Println("b6:", b6)
    fmt.Println("b4:", b4)
    fmt.Println()

    array4 := [4]int{4, -4, 4, -4}
    s6 := []int{1, 1, -1, -1, 5, -5}
    copy(s6, array4[0:])
    fmt.Println("array4:", array4[0:])
    fmt.Println("s6:", s6)
    fmt.Println()

    array5 := [5]int{5, -5, 5, -5, 5}
    s7 := []int{7, 7, -7, -7, 7, -7, 7}
    copy(array5[0:], s7)
    fmt.Println("array5:", array5)
    fmt.Println("s7:", s7) 
}
$ go run foo.go
a6: [-10 1 2 3 4 5]
a4: [-1 -2 -3 -4]
a6: [-1 -2 -3 -4 4 5]
a4: [-1 -2 -3 -4]

b6: [-10 1 2 3 4 5]
b4: [-1 -2 -3 -4]
b6: [-10 1 2 3 4 5]
b4: [-10 1 2 3]

array4: [4 -4 4 -4]
s6: [4 -4 4 -4 5 -5]

array5: [7 7 -7 -7 7]
s7: [7 7 -7 -7 7 -7 7]

a4를 a6로 복사하는데 a6의 원소의 수가 a4보다 더 많기 때문에 a4의 모든 원소가 a6로 복사된다.

b6의 첫 네원소만 b4로 복사된다.

다차원 슬라이스

package main

import "fmt"

func main() {
    aSlice := []int{1,2,3,4,5}
    fmt.Println(aSlice) 
    integer := make([]int, 2) 
    fmt.Println(integer)
    integer = nil 
    fmt.Println(integer)

두 개의 슬라이스를 정의한다

anArray := [5]{-1,-2,-3,-4,-5}
refAnArray := anArray[:]

fmt.Println(anArray)
fmt.Println(refAnArray)
anArray[4] = -100
fmt.Println(refAnArray)

[:] 기호를 사용해 기존 배열을 참조하는 슬라이스를 새로 생성한다. 이 때 배열의 복사본이 생성되는 것이 아닌 단지 배열을 참조하기만 한다는 점을 주의하자.

s := make([]byte, 5)
fmt.Println(s)
twoD := make([][]int, 3)
fmt.Println(twoD)
fmt.Println()

슬라이스는 Go에서 자동으로 초기화해주기 때문에 두 슬라이스에 대해서 모든 원소는 0에 해당하는 값으로 초기화 된다. (슬라이스는 nil). 다차원 슬라이스의 원소는 슬라이스라는 것을 명심하자.

for i := 0; i < len(twoD); i++ {
        for j := 0; j < 2; j++ {
            twoD[i] = append(twoD[i], i * j)
        }
}

기존 슬라이스를 더 크게 확장하려면, append() 함수를 사용해야 한며 존재하지 않는 인덱스를 참조하면 안 된다는 것을 알 수 있다.

for _, x := range twoD {
        for i, y := range x {
            fmt.Println("i : ", i, "value : ", y)
        }
        fmt.Println() 
}
}
$ go run foo.go
[1 2 3 4 5]
[0 0]
[]
[-1 -2 -3 -4 -5]
[-1 -2 -3 -4 -5]
[-1 -2 -3 -4 -100]
[0 0 0 0 0]
[[] [] []]

i :  0 value :  0
i :  1 value :  0

i :  0 value :  0
i :  1 value :  1

i :  0 value :  0
i :  1 value :  2

sort.Slice()로 슬라이스 정렬하기

package main

import (
    "fmt"
    "sort"
)
type aStructure struct {
    person string
    height int
    weight int
}

func main() {
    mySlice := make([]aStructure, 0)
    mySlice = append(mySlice, aStructure{"a", 180, 90})
    mySlice = append(mySlice, aStructure{"b", 134, 70})
    mySlice = append(mySlice, aStructure{"c", 155, 60})
    mySlice = append(mySlice, aStructure{"d", 144, 50})
    mySlice = append(mySlice, aStructure{"e", 134, 40})

    fmt.Println("0:", mySlice)

mySlice라는 이름의 슬라이스를 새로 정의한다

    sort.Slice(mySlice, func(i, j int) bool {
        return mySlice[i].height < mySlice[j].height
    })
    fmt.Println("<:", mySlice)

    sort.Slice(mySlice, func(i, j int) bool {
        return mySlice[i].height > mySlice[j].height
    })
    fmt.Println("<:", mySlice)
}
go run foo.go
0: [{a 180 90} {b 134 70} {c 155 60} {d 144 50} {e 134 40}]
<: [{b 134 70} {e 134 40} {d 144 50} {c 155 60} {a 180 90}]
<: [{a 180 90} {c 155 60} {d 144 50} {b 134 70} {e 134 40}]

Go에서 맵

맵은 다른 언어의 해시 테이블과 같다. 키에 지정할 데이터 타입은 반드시 비교할 수 있어야 한다. 다시 말해 Go 컴파일러가 여러 값들의 차이를 알아낼 수 있어야 한다 (== 연산을 할 수 있어야 한다)

  • Go 언어의 맵은 해시 테이브에 대한 참조값이다. 한 가지 좋은 점은 Go 언어는 이러한 해시 테이블의 구현 사항을 가려주기 때문에 복잡도를 낮출 수 있다는 것이다.
iMap = make(map[string]int)

make() 함수에 string 타입의 키와 int 값을 지정하여 빈 맵을 생성한다

anotherMap := map[string]int {
    "k1" : 12,
    "k2" : 13,
}

위와 같이 map literal을 통해 데이터를 직접 지정할 수도 있다

delete(anthoerMap, "k1")
for key, value := range iMap {
    fmt.Pritnln(key, value)
}

맵의 원소와 키 값을 순회할 수 있다.

package main

import (
    "fmt"
)

func main() {
    iMap := make(map[string]int)
    iMap["k1"] = 12
    iMap["k2"] = 13
    fmt.Println("iMap:", iMap)

    anotherMap := map[string]int {
        "k1" : 12,
        "k2" : 13,
    }

    fmt.Println("anotherMap : ", anotherMap)
    delete(anotherMap, "k1")
    delete(anotherMap, "k1")
    fmt.Println("anotherMap : ", anotherMap)

    _, ok := iMap["hi"]
    if ok {
        fmt.Println("yes")
    } else {
        fmt.Println("no")
    }
}
$ go run foo.go
iMap: map[k1:12 k2:13]
anotherMap :  map[k1:12 k2:13]
anotherMap :  map[k2:13]
no
  • 맵에 존재하지 않는 키 값을 구하면 0을 얻게 된다. 요청한 키가 없어서 0이 나왔는지 아니면 그 키 값이 가리키는 원소의 값이 실제로 0인지 구별할 수 없다.

또한 delete() 함수를 여러 차례 호출하더라도 아무런 경고 메시지 없이 결과 나오는 것을 알수 있다.

nil 맵에 저장하기

aMap := map[string]int{}
aMap["test"] = 1

aMap = nil 
fmt.Println(aMap)
aMap["test"] = 1

실행되지 않는데 nil에 저장할 수 없기 때문이다

Go 상수

Go 언어는 상수를 지원한다.

  • 일반적으로 상수는 주로 전역 변수로 사용한다.

상수의 장점은 프로그램이 실행되는 동안 그 값이 변하지 않는다는 것을 보장할 수 있다는 점이다.

엄밀히 말해서 상수 변수의 값은 run-time이 아닌 compile-time에 의해 결정된다. Go언어에서 내부적으로 처리하는 과정을 보면, 불리언, 스트링, 숫자 타입으로 상수 변수를 저장한다.

Go 컴파일러는 상수에 적용된 연산의 결과도 모두 상수처럼 취급한다.

const s1 = "hi"
const s2 string = "hi"

s2에 string이라는 타입 선언이 붙었느데, 이렇게 하면 s1보다 좀 더 엄격하게 선언된다. 타입을 지정한 상수는 타입을 지정한 변수에 적용되는 모든 규칙이 그대로 적용되기 때문이다.

타입을 지정하지 않은 상수도 디폴트 타입을 가지는데, 아무런 타입 정보를 주지 않을 때만 이 규칙이 적용된다. 이런 시긍로 정해둔 주된 이유는 상수를 어떻게 활용할 지 모르기 때문이다. 그래서 Go 언어에서 족용하는 모든 규칙을 따르지 않는다.

예를 들어 const value = 123와 같은 숫자 상수를 정의하는 경우, value는 여러 표현식에서 사용할 수 도 있기 때문에, 상수를 서언할 때 타입을 지정해버리면 다른 작업하기 까다로워 질수 있다.

const s1 = 123
const s2 float64 = 123
var v1 float32 = s1 * 12
var v2 float32 = s2 * 12

s2와 v2가 서로 다른 타입으로 정의됬기 때문에 컴파일에러가 발생한다.

  • 코드 전바에 걸쳐 상수를 많이 사용한다면 모두 모아서 Go 패키지로 만드는 것이 좋다

상수 생성자 iota

iota는 서로 관련된 값들이 순차적으로 증가하도록 선언할 때 사용한다.

package main

import (
    "fmt"
)

type Digit int 
type Power2 int 
const PI = 3.1415926 
const (
    C1 = "C1C1C1"
    C2 = "C2C2C2"
    C3 = "C3C3C3"
)

두개의 타입과 네 개의 상수를 선언하고 있다

  • type 키워드는 다른 이름으로 정의할 대 사요하며, 내부 타입을 그대로 사용한다.
func main() {
    const s1 = 123
    var v1 float32 = s1 * 12 
    fmt.Println(v1)
    fmt.Println(PI)

또 다른 상수를 정의한다

const (
        Zero Digit = iota
        One
        Two
        Three
        Four
    )
    fmt.Println(One)
    fmt.Println(Two)

Digit 타입의 상수를 연속으로 정의하도록 iota를 지정했다.

const (
        p2_0 Power2 = 1 << iota 
        _
        p2_2
        _
        p2_4
        _
        p2_6
    )
    fmt.Println("2^0:", p2_0)
    fmt.Println("2^2:", p2_2)
    fmt.Println("2^4:", p2_4)
    fmt.Println("2^6:", p2_6)
}

먼저 상수 생성자 iota를 사용할 때 const 블록에 언더스코어를 사용했다. 이로 불필요한 값을 건너뛸 수 있다.

p2_0에서 iota는 0이고, 1을 왼쪽으로 0번 shift해서 1로 지정했다.

$ go run foo.go
1476
3.1415926
1
2
2^0: 1
2^2: 4
2^4: 16
2^6: 64

Go 언어 포인터

Go는 포인터를 지원한다. 포인터는 쉽게 메모리 주소이다.

포인터가 가리키는 값은 *연산으로 알아낸다. 이렇게 표현하는 것을 dereference라 말한다. 포인터로 선언하지 않은 변수에 대한 메모리 주소를 가져올 때는 & 연산을 사용한다.

package main

import (
    "fmt"
)

func getPointer(n *int) {
    *n = *n * *n
}
func returnPointer(n int) *int {
    v := n*n
    return &v
}

getPointer와 같이 매개변수로 포인터를 전달하면 변수를 업데이트 할 수 있다는 장점이 있다. 이 함수를 호출한 코드로 결과를 리턴하지 않는다. 매개변수로 전달한 포인터에 업데이트할 변수의 메모리 주소가 담겨 있기 때문이다.

returnPointer는 정수형 매개변수를 받아서 정수값에 대한 포인터를 리턴한다.

func main() {
    i := -10
    j := 25 
    pI := &i 
    pJ := &j 

    fmt.Println("pI memory :", pI)
    fmt.Println("pI memory :", pJ)
    fmt.Println("pI value :", *pI)
    fmt.Println("pI value :", *pI)

i, j는 모두 일반 정수형 변수다. 반해 pI, pJ는 각각 i, j를 가리키는 포인터다.

    *pI = 123456
    *pI-- 
    fmt.Println("i:", i)

포인터 pI를 통해 i값을 변경할 수 있다.

    getPointer(pJ)
    fmt.Println("j:", j)
    k := returnPointer(12) 
    fmt.Println(*k) 
    fmt.Println(k)

}

getPointer()에 pJ를 매개변수로 전달해서 호출했다. getPointer()안에서 pJ 변수를 통해 j값을 변경한다.

$ go run foo.go
pI memory : 0xc0000b4008
pI memory : 0xc0000b4010
pI value : -10
pI value : -10
i: 123455
j: 625
144
0xc0000b4048

날짜와 시간 다루기

시간과 날짜를 다루는것은 중요하지 않은 기능처럼 보이지만, 여러 태스크를 동기화할때나 애플리케이션에서 여러 텍스트 프일을 읽거나 사용자로 부터 직접 입력을 받을 때 중요하다.

time 패키지는 go 언어에서 날짜와 시간을 다루는데 핵심적인 역할을 한다.

package main

import (
    "fmt"
    "time"
)

func main() {
    fmt.Println("Epoch time : ", time.Now().Unix()) 
    t := time.Now()
    fmt.Println(t, t.Format(time.RFC3339))
    fmt.Println(t.Weekday(), t.Day(), t.Month(), t.Year())

    time.Sleep(time.Second)
    t1 := time.Now()
    fmt.Println("Time difference :", t1.Sub(t))

time.Now().Unix() 함수는 유닉스 에포크 시간을 리턴한다. 이 값은 1970년 ~로 부터 경과된 시간을 초 단위로 표현한 숫자이다. time.Format() 함수를 사용하면 time 변수를 다른 포맷으로 변환할 수 있다.

time.Second 상수를 이용하면 Go 언어에서 1초의 경과 시간을 표현할 수 있다.

    formatT := t.Format("01 Jan 2006")
    fmt.Println(formatT)
    loc, _ := time.LoadLocation("Europe/Paris")
    londonTime := t.In(loc)
    fmt.Println("Paris:", londonTime)
}
$ go run foo.go
Epoch time :  1599404227
2020-09-06 23:57:07.127177 +0900 KST m=+0.000156456 2020-09-06T23:57:07+09:00
Sunday 6 September 2020
Time difference : 1.00189208s
09 Sep 2020
Paris: 2020-09-06 16:57:07.127177 +0200 CEST

시간 다루기

time 변수가 정의되어 있으면, 시간이나 날짜와 관련된 값으로 변환하는 것은 쉽게 처리할 수 있다. 하지만 스트링으로 표현된 상태에서 실제 그 값이 유효한 시간을 나타내는지 확인하려면 별도의 작업이 필요하다. 이 때 time.Parse()를 사용한다. 두개의 매개변수를 받는대, 첫번째는 포맷이고, 두번째는 스트링이다. 첫 번째 매개변수는 go가 지원하는 상수 또는 정규표현식을 사용할 수 있다.

package main

import (
    "fmt"
    "os"
    "path/filepath"
    "time"
)

func main() {
    var myTime string
    if len(os.Args) != 2 {
        fmt.Printf("usagae : %s string\n", filepath.Base(os.Args[0]))
        return
    }
    myTime = os.Args[1]
    d, err := time.Parse("15:04", myTime)
    if err == nil {
        fmt.Println("Full:", d)
        fmt.Println("Time:", d.Hour(), d.Minute())
    } else {
        fmt.Println(err)
    }
}
$ go run foo.go 12:10
Full: 0000-01-01 12:10:00 +0000 UTC
Time: 12 10

날짜 다루기

날짜를 파싱할 때로 time.Parse()를 사용하는데, 월에 해당하는 값은 Jan처럼 세 개의 문자로 표현하고, 연도는 2006과 같이 표현하고, 날짜는 02와 같이 표현한다.

날짜 파싱하기

package main

import (
    "fmt"
    "os"
    "path/filepath"
    "time"
)

func main() {
    var myDate string
    if len(os.Args) != 2 {
        fmt.Printf("usagae : %s string\n", filepath.Base(os.Args[0]))
        return
    }
    myDate = os.Args[1]
    d, err := time.Parse("02 Jan 2006", myDate)
    if err == nil {
        fmt.Println("Full:", d)
        fmt.Println("Time:", d.Day(), d.Month(), d.Year())
    } else {
        fmt.Println(err)
    }
}
$ go run foo.go "24 Dec 1994"
Full: 1994-12-24 00:00:00 +0000 UTC
Time: 24 December 1994

위 예제에서는 시간을 표현하는 값을 받지 않기 때문에, 날짜 뒤에는 초기값으로 스트링을 덧붙인다.