문자열

문자의 집합(배열)이라는 의미이다.

기존의 문자는 ASCII코드로 1byte를 갖기 때문에 0~255로 총 255개의 문자를 표현할 수 있었는데 이 수로는 현재 존재하는 언어(문자)들을 모두 표시할 수 없기 때문에 더 큰 byte의 문자가 필요해졌다. 대표적으로 UTF-8이 있는데 이는 한 문자당 1~4byte를 갖고 UTF-162byte를 갖는다.


1. UTF-8과 UTF-16

UTF-16이 공간을 더 적게 차지하기 때문에 UTF-16이 더 좋아보일 수 있다. 하지만, UTF-8은 파레토의 법칙으로 데이터를 잘 봐보니 영어/숫자의 데이터가 대부분(80%)를 차지하더라라는 개념으로 필요한 데이터크기에 맞게 크기를 할당하는 개념으로 크기가 동적으로 변한다.

UTF-16은 2byte로 고정길이기 때문에 UTF-8이 더 효율적일 수도 있고 ASCII로 표현되는 데이터들을 2byte로 표현을 하다보니까 ASCII와 상호간에 호환이 잘 안되는 문제점이 있다.



2. 문자열 리터럴 표현 방식

◾ Double Quote ("")

str := "\""
fmt.Println(str)      //"

str1 := "Hello\nWorld"
fmt.Println(str1)     //Hello
                      //World

str2 := "Hello
World"    //error

""은 한 줄로 표현할 때 사용하며 개행을 하기 위해선 개행문자 \n을 이용해서 해야하고 예제처럼 한줄 띄어서 작성할 수 없다.

Go에서 쌍따옴표로 열리고 닫힌 string을 Interprected string이라고 한다. 이 문자열은 escape 문자(\)를 이용해서 특수기호를 표시할 수 있다.


◾ Back Tick (``)

str := `"`
fmt.Println(str)      //"

str1 := `Hello\nWorld`
fmt.Println(str1)     //Hello\nWorld

str2 := `Hello
World`
fmt.Println(str2)     //Hello
                      //World

여러줄로 표현 가능하고 이를 이용한 string을 Raw string이라고 하고 이는 내부 특수기호를 그대로 표현해준다.



3. 문자열 순회

str := "Hello 월드"

ftm.Println(len(str))     //case 1

for i:=0; i < len(str); i++ {
  fmt.Printf("타입: %T, 값: %d, 문자값: %c\n",str[i],str[i],str[i])
}
fmt.Printf("%c",str[8])   //”

위에서 case1의 출력결과는 무엇일까?

우리 예상은 8일 것 같지만 len()은 문자열의 바이트 길이를 반환하기 때문에 한글은 3byte씩 해서 총 12가 출력된다. 그렇기 때문에 위의 for문을 실행해보면 한글이 나오는 부분부터 깨지는 것을 볼 수 있다.

[]를 이용해 문자열을 접근하는 것은 len과 같이 메모리의 index에 접근하는 것이기 때문에 3byte로 저장된 한글 한문자중에서 1byte만 접근해 쓰레기값이 나오는 것이다.


◾ []rune

str := "Hello 월드"
arr := []rune(str)

for i:=0; i < len(arr); i++ {
  fmt.Printf("타입: %T, 값: %d, 문자값: %c\n",arr[i],arr[i],arr[i])
}

ftm.Println(len(arr))     //8

rune은 int32의 별칭타입이기 때문에 4byte를 갖는다. 이를 이용해서 문자열을 rune타입 슬라이스로 만들어서 배열을 순회하면 우리가 원하는데로 동작한다.


◾ range

str := "Hello 월드"

for _,v := range str {
  fmt.Printf("타입: %T, 값: %d, 문자값: %c\n",v,v,v)
}

정확한 길이가 필요한 것이 아니라 단순히 순회가 목적이라면 range를 이용하면 편하게 순회 할 수 있다.



4. 문자열 합산

str1 := "Hello"
str2 := "World"

str3 := str1 + " " + str2
fmt.Println(str3)

str3 += "Hi "
fmt.Println(str3)

go는 +를 통해 문자열 합을 지원하고 ptyhon같이 그 외의 연산(*…)을 지원하지 않는다.



5. 문자열 비교

◾ ==, !=

str1 := "Hello 월드"
str2 := "Hello 월드"
fmt.Println(str1 == str2)   //true

str1 := "Hello 월드\n"
str2 := `Hello 월드
`
fmt.Println(str1 == str2)   //true

문자열 두개가 서로 같은지 비교하는 연산을 지원한다.

◾ <, > , <= ,>=

str1 := "apple"
str2 := "Apple"

fmt.Println(str1 > str2) //true

go 는 대소 비교도 지원하는데 이때 사전식 비교로 대문자가 더 작다.

Ascii 코드 값

  • A-Z : 65 - 90

  • a-z : 97 - 122



6. 문자열 구조

지금까지 Go를 착실히 공부했다면 여기서 한가지 의문점이 들 것이다.

분명히 Go는 강타입 언어라서 연산,대입을 할때 데이터타입이 크기까지 똑같아야 했다. int8과 int32 두 타입의 연산을 시도하면 error가 발생한다.
그런데 string은 데이터크기가 다 제각각인데 어떻게 합이나 비교, 대입이 가능할까?


type StringHeader struct{
  Data uintptr
  Len int
}

문자열은 내부적으로 데이터(문자열 리터럴)의 주소값을 포인터변수로 가지고 있기 때문에 string의 길이가 서로 달라도 연산이 가능하고 대입연산도 가능하다.


import (
  "fmt"
  "reflect"
  "unsafe"
)

func main(){
  str1 := "Hello 월드"
  str2 := str1

  stringHeader1 := (*reflect.StringHeader)(unsafe.Pointer(&str1))
  stringHeader2 := (*reflect.StringHeader)(unsafe.Pointer(&str2))

  fmt.Println(stringHeader1)    //&{49633883 12}
  fmt.Println(stringHeader2)    //&{49633883 12}
}

문자열을 poninter타입으로 변환후 reflect를 이용해 내부를 봐보면, 실제 문자열이 저장된 위치(49633883)길이(12)가 들어있는 것을 볼 수 있다.


import (
  "fmt"
  "unsafe"
  "reflect"
)

func main(){
  str1 := "Hello"
  str2 := "Hello"

  stringHeader1 := (*reflect.StringHeader)(unsafe.Pointer(&str1))
  stringHeader2 := (*reflect.StringHeader)(unsafe.Pointer(&str2))

  fmt.Println(stringHeader1)    //&{4989886 5}
  fmt.Println(stringHeader2)    //&{4989886 5}
}

str2에 str1을 할당하는 것이 아니라 같은 문자 리터럴을 대입해도 같은 공간을 가리키는 것을 볼 수 있는데, 이는 메모리(스택이나 힙) 또는 코드공간에 해당 문자열이 있는지 확인하고 있다면 해당 공간의 주소를 대입시켜주기 때문이다.



7. 문자열 불변

str1 := "Hello World"
slice := []byte(str)

str1[2] = 'a' //error
slice[2] = 'a'

fmt.Println(str1)         //Hello World
fmt.Printf("%s\n",slice)  //Healo Wolrd

문자열은 Immutable한 속성을 가지고 있어 문자리터럴값은 내부적으로 변경이 불가능하다. 그래서 문자열을 []로 접근해서 일부만 수정하려고 하면 error가 발생한다.

또한, Slice로 문자열을 복사하게 되면 두개의 문자열은 서로 다른 메모리(공간)으로 복사를 하기 때문에 slice를 수정을 해도 기존의 문자열은 변경이 되지 않는다.


import (
  "fmt"
  "unsafe"
  "reflect"
)

func main(){
  str1 := "Hello "
  str2 := str1

  stringHeader1 := (*reflect.StringHeader)(unsafe.Pointer(&str1))
  stringHeader2 := (*reflect.StringHeader)(unsafe.Pointer(&str2))

  fmt.Println(stringHeader1)    //&{4990223 6}
  fmt.Println(stringHeader2)    //&{4990223 6}

  str2 += "World"

  fmt.Println(stringHeader1)    //&{4990223 6}
  fmt.Println(stringHeader2)    //&{824634237040 11}
}

두개의 문자열이 처음에는 같은 메모리를 가리키고 있다가 +연산을 수행하게 되면 기존의 문자열을 바꾸는 것이 아니라 새로운 공간에 합한 문자열을 할당하는 것을 볼 수 있다.



8. String Builder

import (
  "strings"
  "fmt"
)

func main(){
  str := "hello world"
  builder := strings.Builder{}

  for _,c := range str {
    if c >= 97 && c <= 122{
      builder.WriteRune('A' + (c - 'a'))
    }else {
      builder.WriteRune(c)
    }
  }
  fmt.Println(builder.String())     //HELLO WORLD
  fmt.Println(strings.ToUpper(str)) //HELLO WORLD
}

Java의 StringBuilder와 동일한 역할의 string builder이다.

위에서 본 것처럼 문자열 합산연산시 +은 계속 새로운 공간을 할당해서 연산이 수행되는데 많은 문자열을 처리하는데 있어 이는 매우 비효율적이다. 계속 공간을 할당하고 GC가 지우고를 반복하기 때문이다.

그런데 strings.Builder를 이용하면 공간할당을 새로하지 않으면서 붙일 수 있다. Builder 구조체 내부에 byte슬라이스([]byte)를 이용해서 문자열을 관리하기 때문에 +연산시 새로 공간이 할당되는 문제를 해결할 수 있다.





Reference

『Tucker의 Go 언어 프로그래밍』 스터디 요약 노트

Tucker의 Go 강좌