문자열
- Golang
- 2021년 6월 7일
문자의 집합(배열)이라는 의미이다.
기존의 문자는 ASCII코드로 1byte를 갖기 때문에 0~255로 총 255개의 문자를 표현할 수 있었는데 이 수로는 현재 존재하는 언어(문자)들을 모두 표시할 수 없기 때문에 더 큰 byte의 문자가 필요해졌다. 대표적으로 UTF-8이 있는데 이는 한 문자당 1~4byte를 갖고 UTF-16은 2byte를 갖는다.
1. UTF-8과 UTF-16
UTF-16이 공간을 더 적게 차지하기 때문에 UTF-16이 더 좋아보일 수 있다. 하지만, UTF-8은 파레토의 법칙으로 데이터를 잘 봐보니 영어/숫자의 데이터가 대부분(80%)를 차지하더라 라는 개념으로 필요한 데이터크기에 맞게 크기를 할당하는 개념으로 크기가 동적으로 변한다.
UTF-16은 2byte로 고정길이기 때문에 UTF-8이 더 효율적일 수도 있고 ASCII로 표현되는 데이터들을 2byte로 표현을 하다보니까 ASCII와 상호간에 호환이 잘 안되는 문제점이 있다.
2. 문자열 리터럴 표현 방식
1) Double Quote ("")
str := "\""
fmt.Println(str) //"
str1 := "Hello\nWorld"
fmt.Println(str1) //Hello
//World
str2 := "Hello
World" //error
"" 은 한 줄로 표현할 때 사용하며 개행을 하기 위해선 개행문자 \n 을 이용해서 해야하고 예제처럼 한줄 띄어서 작성할 수 없다.
Go에서 쌍따옴표로 열리고 닫힌 string을 Interprected string이라고 한다. 이 문자열은 escape 문자()를 이용해서 특수기호를 표시할 수 있다.
2) 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만 접근해 쓰레기값이 나오는 것이다.
1) []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타입 슬라이스로 만들어서 배열을 순회하면 우리가 원하는데로 동작한다.
2) 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. 문자열 비교
1) ==, !=
str1 := "Hello 월드"
str2 := "Hello 월드"
fmt.Println(str1 == str2) //true
str1 := "Hello 월드\n"
str2 := `Hello 월드
`
fmt.Println(str1 == str2) //true
문자열 두개가 서로 같은지 비교하는 연산을 지원한다.
2) <, > , <= ,>=
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 언어 프로그래밍』 스터디 요약 노트