본문 바로가기

Backend/Golang

Go 언어 - [3장] (문자열 및 자료구조)

※ 디스커버리 Go 언어 라는 책 내용을 전반적으로 다루지만 책 리뷰보다는 좀 더 간단하게 정리해보겠습니다.

 

3. 문자열 및 자료구조

 

이번 장에서는 문자열 처리, 배열과 슬라이스, 맵과 같은 자료구조, 기본적인 입출력에 대해 보도록 하겠습니다.

 

3.1 문자열

 

문자열은 바이트들이 연속적으로 나열되어 있는 것이며, Go 에서는 string 자료형을 이용합니다.

바이트들의 연속을 나타내는 다른 방식으로 []byte 는 변경이 가능하지만, string 은 읽기 전용입니다.

 

3.1.1 유니코드 처리

 

Go 언어의 소스 코드는 UTF-8 로 되어 있고, 코드 상에 표시된 문자열 역시 UTF-8 로 인코딩되어 있습니다.

 

for i, r := range "가나다" {
  fmt.Println(i, r)
}
fmt.Println(len("가나다"))

// Output
0 44032
3 45208
6 45796
9

 

"가나다" 문자열을 for 문으로 돌면서 바이트위치 (i) 와 문자 (r) 를 출력합니다. 여기서 i 는 int 형이고 r 은 rune 형인데, rune 은 int32 (32비트 정수) 이며 유니코드 포인트 하나를 담을 수 있습니다. 바이트 위치가 0 3 6 인 이유는 "가나다" 한 글자당 3바이트가 필요하기 때문입니다. rune 형이 아닌 해당 문자를 찍어보고 싶을땐, string(r) 을 해주면 r 을 해당 유니코드 문자열로 바꿔줍니다.

 

for _, r := range "가갛힣" { // <- 사용하지 않을 값은 밑줄 문자로 무시 가능
  fmt.Println(string(r), r)
}

// Output
가 44032
갛 44059
힣 55203

 

3.1.2 테스트

 

이번 장의 내용과는 다소 거리가 있지만 Go 에서는 테스트 코드를 어떻게 작성하는지 잠깐 보도록 하겠습니다.

 

// hangul.go
package hangul

var (
  start = rune(44032) // 가
  end = rune(55024) // 힣
)

// 문자열의 마지막 문자가 받침이 있으면 true 를 반환하는 함수
func HasConsonantSuffix(s string) bool {
  numEnds := 28
  result := false
  for _, r := range s {
    if start <= r && r < end {
      index := int(r - start)
      result = index % numEnds != 0
    }
  }
  
  return result
}
// hangul_test.go
package hangul

import "fmt"

func ExampleHasConsonantSuffix() {
  fmt.Println(HasConsonantSuffix("Go 언어"))
  fmt.Println(HasConsonantSuffix("그럼"))
  fmt.Println(HasConsonantSuffix("우리 밥 먹고 합시다."))
  // Output:
  // false
  // true
  // false
}

 

위처럼 같은 위치에 두 파일을 작성하는데, 테스트 파일엔 _test 를 붙이고 테스트할 함수엔 Example 을 붙여야 합니다.

 

> go test hangul_test.go hangul.go
> go test github.com/{username}/hangul <- 디렉토리 경로를 통째로 입력해도 된다

 

위 명령어로 테스트를 수행할 수 있으며, 주석처리한 Output 부분이 테스트 시 검사가 됩니다.

 

3.1.3 바이트 단위 처리

 

유니코드 문자 단위가 아닌, 바이트 단위로 읽어보도록 하겠습니다.

 

func printBytes2() {
  s := "가나다"
  fmt.Printf("%x\n", s) // %x 는 16진수 숫자 형식으로 출력
  fmt.Printf("% x\n", s) // 바이트 단위로 한 칸씩 띄워서 출력
}

// Output
eab080eb8298eb8ba4
ea b0 80 eb 82 98 eb 8b a4

 

3.1.4 패키지 문서

 

https://golang.org/pkg/ 이 링크에는 Go 에서 지원하는 모든 표준 라이브러리가 문서화되어 있습니다. 예를 들어 fmt 패키지의 다양한 함수들에 대해 보고 싶다면, https://golang.org/pkg/fmt/ 페이지를 방문하면 됩니다.

 

3.1.5 문자열 잇기

 

문자열은 읽기 전용이라 이어붙이는 작업은 문자열을 수정하는 것이 아닌 새로 만드는 것입니다.

 

func strCat() {
  s := "abc"
  ps := &s
  s += "def" // 간단하게 + 연산으로 문자열을 이어붙일 수 있음
  fmt.Println(s) / -> abcdef
  fmt.Println(*ps) / -> abcdef
}

 

Go 의 문자열은 포인터와 비슷하기 때문에 위와 같이 s 에 값을 변경하면 ps 도 영향을 받습니다.

+ 연산 외의 방법으로는 아래와 같은 방법이 있습니다.

  • s = fmt.Sprint(s, "def") : 문자열이 아닌 것들도 이어붙일 수 있음
  • s = fmt.Sprintf("%sdef", s) : 형식 지정 가능
  • s = strings.Join([]string{s, "def"}, "") : 문자열 슬라이스나 배열이라면 이용 가능

 

3.1.6 문자열을 숫자로

 

Go 에서는 "5" 문자열을 5 정수형으로 바꾸고 싶을 경우 int('5') 를 이용하면 해당 문자의 유니코드의 코드 포인트 숫자로 변환되므로 strconv 패키지의 Atoi() 와 같은 함수를 사용해야 합니다.

 

// strconv 패키지 이용
var i int
var k int64
var f float64
var s string
var err error
i, err = strconv.Atoi("350") / -> i == 350
k, err = strconv.ParseInt("cc7fdd", 16, 32) / -> k == 13402077
k, err = strconv.ParseInt("0xcc7fdd", 0, 32) / -> k == 13402077
f, err = strconv.ParseFloat("3.14", 64) / -> f == 3.14
s = strconv.Itoa(340) / -> s == "340"
s = strconv.FormatInt(13402077, 16) / -> s == "cc7fdd"

// fmt 패키지 이용
var num int
fmt.Sscanf("57", "%d", &num) / -> num == 57
var s string
s = fmt.Sprint(3.14) / -> s == "3.14"
s = fmt.Sprintf("%x", 13402077) / -> s == "cc7fdd"

 

3.2 배열과 슬라이스

 

배열이 직접 사용되는 경우도 있지만 Go 에서는 주로 슬라이스를 이용하여 간접적으로 배열을 이용합니다.

 

3.2.1 배열

 

func array() {
  fruits := [3]string{"사과", "바나나", "토마토"}
  // fruits := [...]string{"사과", "바나나", "토마토"} -> 컴파일러가 배열의 개수를 알아내어 넣게 만들때
  for _, fruit := range fruits {
    fmt.Printf("%s는 맛있다.\n", fruit)
  }
}
// Output
사과는 맛있다.
바나나는 맛있다.
토마토는 맛있다.

 

3.2.2 슬라이스

 

슬라이스는 길이와 용량을 갖고 있고 길이가 변할 수 있는 자료구조입니다. 위의 배열 선언에서 [3] 을 [] 로 변경한 것이 슬라이스입니다.

기본적으로 빈 슬라이스에는 nil 값이 들어갑니다. (자바의 null 과 비슷합니다)

 

var fruits []string / -> 기본적인 슬라이스 선언
fruits := make([]string, n) / -> 슬라이스 크기를 미리 알고 선언할 때 사용되며, 해당 자료형의 기본값이 들어간다. string 의 경우 ""

// 쉽게 잘라낼 수 있는 슬라이스 예제
nums := []int{1, 2, 3, 4, 5}
fmt.Println(nums) / -> [1 2 3 4 5]
fmt.Println(nums[1:3]) / -> [2 3]
fmt.Println(nums[2:]) / -> [3 4 5]
fmt.Println(nums[:3]) / -> [1 2 3]

 

Go 에서의 슬라이싱이 다른 언어와 다른 점은 음수를 쓸 수 없다는 것입니다. 또한 범위가 넘어가면, 패닉이 발생합니다.

 

3.2.3 슬라이스 덧붙이기

 

append 함수는 가변 인자를 받는 함수기 때문에 아래와 같이 사용 가능합니다.

 

fruits = append(fruits, "포도")
fruits = append(fruits, "포도", "딸기")

f1 := []string{"사과", "바나나", "토마토"}
f2 := []string{"포도", "딸기"}
f3 := append(f1, f2...) / -> [사과 바나나 토마토 포도 딸기]
f4 := append(f1[:2], f2...) / -> [사과 바나나 포도 딸기]

 

3.2.4 슬라이스 용량

 

슬라이스는 연속된 메모리 공간을 활용하는 것이라서 용량에 제한이 있을 수 밖에 없습니다. 남은 자리가 없이 덧붙이려면 이전 내용을 복사해 더 넓은 메모리 공간으로 이사 후 해당 공간에 덧붙이게 됩니다.

make([]int, 5) 와 같이 미리 할당한 경우는 길이 뿐만 아니라 용량도 5로 맞춰집니다. 여기서 슬라이스 덧붙이기를 하면 슬라이스 전체 복사가 일어나게 됩니다. 슬라이스의 용량 확인은 cap(x) 를 이용할 수 있습니다.

 

nums := make([]int, 0, 5) // 길이는 0, 용량은 5인 슬라이스 생성

 

위처럼 만약 용량이 5를 넘지 않을 것을 미리 알아 공간을 예약해두면 성능 향상에 도움이 됩니다. (복사가 일어나지 않으므로)

 

3.2.5 슬라이스의 내부 구현

 

슬라이스는 시작 주소, 길이, 용량 이렇게 3개의 필드로 이루어져있습니다. 덧붙이기에 사용하는 append 함수는 nums = append(nums, 10) 와 같이 해당 변수를 두 번 반복해서 쓰는데, 이 때 아래와 같은 일이 일어납니다.

  • len(nums) + 1 <= cap(nums) : 용량을 초과하지 않을 경우 새로운 값을 집어 넣고 길이가 증가한 슬라이스 반환, 길이가 증가한 슬라이스를 nums 에 다시 할당해야 하므로 두 번 반복해서 씀
  • len(nums) +1 > cap(nums) : 용량을 초과할 경우, 배열을 새로 하나 더 만들고 슬라이스를 고쳐서 반환해 재할당해야 하므로 두 번 반복해서 씀

 

3.2.6 슬라이스 복사

 

// 배열을 이용한 방법
sliceCopy() {
  src := []int{30, 20, 50, 10, 40}
  dest := make([]int, len(src))
  for i := range src {
    dest[i] = src[i]
  }
  fmt.Println(dest) / -> [30 20 50 10 40]
}

// Go 에서 지원하는 copy 함수 사용
copy(dest, src)

// dest 크기가 src 를 모두 복사할 정도로 충분치 않을 수 있으므로, 실제로는 이렇게 해야함
src := []int{30, 20, 50, 10, 40}
dest := make([]int, len(src))
copy(dest, src)

 

3.2.7 슬라이스 삽입 및 삭제

 

다른 언어와 달리 Go 언어에서는 슬라이스의 삽입 삭제 메서드가 제공되지 않습니다. 하지만 append 를 사용해 이를 해결할 수 있습니다.

 

// a 슬라이스의 i 번째 원소로 x 를 삽입
a = append(a[:i+1], a[i:]...)
a[i] = x

// a 슬라이스의 i 번째 원소 삭제
a = append(a[:i], a[i+1:]...)

 

하지만 위처럼 삭제시 append 를 사용할 경우 복사가 너무 많이 일어날 수 있고, O(n) 의 시간복잡도가 발생합니다. 만약 순서가 바뀌어도 된다면 O(1) 의 시간복잡도만 발생하는 아래와 같은 방법을 사용할 수 있습니다.

 

a[i] = a[len(a) - 1]
a = a[:len(a) - 1]

 

삭제할땐 중요한 점이, 삭제되는 슬라이스 내부에 포인터가 있는 경우 뒤에 공간이 남아 있으면 가비지 컬렉션이 일어나지 않아 메모리 누수가 일어날 수 있습니다. 이런 경우 해당 포인터를 nil 로 해서 삭제해주어야 합니다.

 

copy(a[i:], a[i+1:])
a[len(a)-1] = nil // 메모리 누수 방지
a = a[:len(a) - 1]

 

3.2.8 스택

 

스택은 LIFO 구조를 갖고 있는 자료구조입니다. Go 에서 스택 자료구조를 따로 지원하진 않지만, 슬라이스로 충분히 구현 가능합니다.

따로 구현해보진 않도록 하겠습니다.

 

3.3 맵

 

Go 언어에서 map 은 해시테이블로 구현됩니다. 해시맵은 키와 값으로 구성되며, 순서가 없습니다.

 

var m map[keyType]valueType // 기본적인 map 정의
m := make(map[keyType]valueType) // 빈 맵 선언
m := map[keyType]valueType{} // 빈 맵 선언

// 맵을 읽을 때
value, ok := m[key] // 두 개의 변수로 받게 되면 두번째 변수엔 키가 존재하는지 여부를 bool 형으로 받음

// 맵에 쓸 때
map[key] = value

 

3.3.1 맵 사용하기

 

func count(s string, codeCount map[rune]int) {
  for _, r := range s {
    codeCount[r]++
  }
}

 

위의 함수는 생성되어 있는 맵을 받아 각 문자가 몇 번 출현하는지 세는 함수입니다. 슬라이스와 다른 점은 맵을 이용할 땐 맵 변수 자체에 다시 할당하지 않으므로 포인터를 취하지 않아도 맵을 변경할 수 있습니다.

맵 자료구조는 순서가 없으므로 테스트하기가 곤란할 경우가 있는데, 아래처럼

 

// k 라는 맵이 있다고 가정
var keys sort.IntSlice
for key := range k {
  keys = append(keys, int(key))
}
sort.Sort(keys)

 

키를 정렬해 키 순서대로 맵의 값을 조회해 테스트를 할 수 있습니다.

 

3.3.2 집합

 

Go 에서는 키의 존재를 확인할 수 있는 집합은 따로 제공하지 않지만, 맵을 이용하면서 값을 bool 형으로 주면 집합을 다룰 수 있습니다.

위에서 다루지 않았는데, 맵과 집합에서 해당 키와 값을 삭제하려면 delete(m, key) 와 같이 사용하면 됩니다.

 

3.3.3 맵의 한계

 

같은 키가 여러번 들어갈 수 있는 맵은 기본적으로 제공되지 않으므로, 독창적인 방법을 통해 구현하거나 외부 라이브러리를 이용하는 방법이 있습니다.

맵은 여러 고루틴에서 동시에 읽기만 하는 것은 괜찮지만 맵의 구조가 변경될 수 있는 경우엔 스레드가 안전하지 않아 문제가 될 수 있습니다. 이러할 경우 락을 이용하거나 다른 라이브러리를 이용할 수 있습니다.

맵의 키에는 string 은 되지만 []byte 는 안되는 것처럼 변경될 수 있는 것이 들어가선 안됩니다. 키가 변경이 될 수 있으면 해시값도 변경될 수 있으므로 해시 테이블이 깨지기 때문입니다.

 

3.4 입출력

 

Go 의 입출력에 대한 표준 라이브러리는 io 에 들어있으며, fmt 패키지 형식을 이용한 입출력 또한 구현되어 있습니다.

 

3.4.1 io.Reader & io.Writer

 

입출력은 io.Reader 와 io.Writer 인터페이스와 파생된 다른 인터페이스들을 이용합니다. fmt 패키지에서는 F 로 시작하는 함수들이 io.Reader 와 io.Writer 를 인자로 받습니다. 함수를 작성할 때 io.Reader 혹은 io.Writer 등을 받아서 처리하게 작성하면 표준 입출력, 파일, 네트워크 등 모두 적용 가능하며 테스트할때도 좋습니다.

 

3.4.2 파일 읽기

 

f, err := os.Open(filename)
if err != nil {
  return err
}
defer f.Close()
var num int
if _, err := fmt.Fscanf(f, "%d\n", &num); err == nil {
  /* */
}

 

파일은 위 코드와 같이 읽을 수 있습니다. os.Open 은 반환값이 둘이며 err 이 nil 일 경우 파일을 성공적으로 연 것입니다. defer 는 해당 함수를 벗어날 때 호출할 함수를 등록하는 역할로서, 함수나 반복문을 빠져나가는 곳이 한군데가 아닐때 사용하면 깔끔합니다.

 

3.4.3 파일 쓰기

 

읽는 방법과 유사합니다.

 

f, err := os.Create(filename)
if err != nil {
  return err
}
defer f.Close()
if _, err := fmt.Fprintf(f, "%d\n", num); err != nil {
  return err
}

 

3.4.4 텍스트 리스트 읽고 쓰기

 

위의 파일 읽고 쓰기 코드와 유사하며, 다만 읽을 때 bufio.Scanner 를 사용하면 줄 단위로 읽고 억지로 줄바꿈 문자를 떼어내지 않아도 되는 유용한 함수입니다. bufio 패키지를 참고하면 좋을 것 같습니다.

 

3.5 Summary

 

  • 문자열은 바이트의 나열로 string 자료형이며 []byte 로 형변환할 수 있음
  • 문자열에 인덱스를 이용하면 해당 위치의 바이트를 가져올 수 있음
  • 문자열 + 연산으로 이을 수 있음
  • 다양한 방법으로 문자열 -> 숫자, 숫자 -> 문자열 변환 가능
  • Go 에선 배열 대신 슬라이스를 주로 이용하며, 다양한 활용방법이 있음
  • Go 에서 맵은 동시에 여러 값 쓰기 불가능
  • io.Reader, io.Writer 를 이용하면 화면, 파일, 소켓에 관계없이 자료를 읽고 쓰기 가능

 

 

출처 : https://book.naver.com/bookdb/book_detail.nhn?bid=10337667