Go 문자열
Go 에서 문자열은 본질적으로 불변의 읽기 전용 바이트 시퀀스입니다. 여기서 '바이트 시퀀스'란 문자열의 하단 데이터가 순서대로 배열된 바이트로 구성되며, 이 바이트들은 연속된 메모리 공간을 차지합니다.
리터럴
앞서 문자열에는 두 가지 리터럴 표현 방식이 있다고 언급했습니다. 일반 문자열과 원시 문자열입니다.
일반 문자열
일반 문자열은 "" 큰따옴표로 표시되며, 이스케이프를 지원하고 다중 행 작성을 지원하지 않습니다. 다음은 일반 문자열의 예시입니다.
"이것은 일반 문자열입니다\n"
"abcdefghijlmn\nopqrst\t\\uvwxyz"이것은 일반 문자열입니다
abcdefghijlmn
opqrst \uvwxyz원시 문자열
원시 문자열은 백쿼트로 표시되며, 이스케이프를 지원하지 않고 다중 행 작성을 지원합니다. 원시 문자열 내의 모든 문자는 줄바꿈과 들여쓰기를 포함하여 그대로 출력됩니다.
`이것은 원시 문자열입니다, 줄바꿈
탭 들여쓰기, \t 탭 문자지만 무효함, 줄바꿈
"이것은 일반 문자열입니다"
종료
`이것은 원시 문자열입니다, 줄바꿈
탭 들여쓰기, \t 탭 문자지만 무효함, 줄바꿈
"이것은 일반 문자열입니다"
종료액세스
문자열은 본질적으로 바이트 시퀀스이므로 인덱스 연산 str[i] 는 i 번째 바이트를 반환하도록 설계되었습니다. 문법적으로 슬라이스와 일치합니다. 예를 들어 문자열의 첫 번째 요소에 액세스합니다.
func main() {
str := "this is a string"
fmt.Println(str[0])
}출력은 문자가 아닌 바이트 인코딩 값입니다.
116문자열 자르기
func main() {
str := "this is a string"
fmt.Println(string(str[0:4]))
}this문자열 요소 수정 시도
func main() {
str := "this is a string"
str[0] = 'a' // 컴파일 통과 불가
fmt.Println(str)
}main.go:7:2: cannot assign to str[0] (value of type byte)문자열을 수정할 수는 없지만 덮어쓸 수는 있습니다.
func main() {
str := "this is a string"
str = "that is a string"
fmt.Println(str)
}that is a string변환
문자열은 바이트 슬라이스로 변환할 수 있으며, 바이트 슬라이스나 바이트 시퀀스도 문자열로 변환할 수 있습니다. 예시는 다음과 같습니다.
func main() {
str := "this is a string"
// 명시적 타입 변환으로 바이트 슬라이스
bytes := []byte(str)
fmt.Println(bytes)
// 명시적 타입 변환으로 문자열
fmt.Println(string(bytes))
}문자열의 내용은 읽기 전용이며 불변이지만, 바이트 슬라이스는 수정할 수 있습니다.
func main() {
str := "this is a string"
fmt.Println(&str)
bytes := []byte(str)
// 바이트 슬라이스 수정
bytes = append(bytes, 96, 97, 98, 99)
// 원본 문자열에 할당
str = string(bytes)
fmt.Println(str)
}문자열을 바이트 슬라이스로 변환한 후 두 사이에는 아무런 연관성이 없습니다. Go 는 바이트 슬라이스에 새 메모리 공간을 할당한 후 문자열의 메모리를 복사하기 때문입니다. 바이트 슬라이스를 수정해도 원본 문자열에는 아무런 영향을 미치지 않습니다. 이는 메모리 안전을 위한 것입니다.
이 경우 변환할 문자열이나 바이트 슬라이스가 크면 성능 오버헤드가 높아집니다. 하지만 unsafe 패키지를 사용하여 복사 없는 변환을 구현할 수 있습니다. 다만 뒤따르는 보안 문제는 스스로 감수해야 합니다. 예를 들어 아래 코드에서 b1 과 s1 의 주소는 동일합니다.
func main() {
s1 := "hello world"
b1 := unsafe.Slice(unsafe.StringData(s1), len(s1))
fmt.Printf("%p %p", unsafe.StringData(s1), unsafe.SliceData(b1))
}0xe27bb2 0xe27bb2길이
문자열의 길이는 실제로 문자의 개수가 아니라 바이트 시퀀스의 길이입니다. 대부분의 경우 ASCII 문자를 처리하므로 각 문자가 정확히 하나의 바이트로 표현되어 바이트 길이와 문자 개수가 우연히 일치합니다. 문자열 길이를 구할 때는 내장 함수 len 을 사용합니다. 예시는 다음과 같습니다.
func main() {
str := "this is a string" // 길이가 16 으로 보임
str2 := "이것은 문자열입니다" // 길이가 7 로 보임
fmt.Println(len(str), len(str2))
}16 21중국어 문자열이 영어 문자열보다 짧아 보이지만 실제로 구한 길이는 영어 문자열보다 깁니다. 이는 unicode 인코딩에서 한자가 대부분의 경우 3 바이트를 차지하고 영어 문자는 1 바이트만 차지하기 때문입니다. 문자열의 첫 번째 요소를 출력하면 결과를 알 수 있습니다.
func main() {
str := "this is a string"
str2 := "이것은 문자열입니다"
fmt.Println(string(str[0]))
fmt.Println(string(str2[0]))
fmt.Println(string(str2[0:3]))
}t // 알파벳 t
ì // 한자 '이' 의 '조각' (첫 번째 바이트) 인코딩 값, 우연히 이탈리아어 문자 è 의 인코딩 값과 동일함
이 // 한자복사
배열 슬라이스 복사 방식과 유사하게 문자열 복사는 실제로 바이트 슬라이스 복사입니다. 내장 함수 copy 를 사용합니다.
func main() {
var dst, src string
src = "this is a string"
desBytes := make([]byte, len(src))
copy(desBytes, src)
dst = string(desBytes)
fmt.Println(src, dst)
}strings.Clone 함수를 사용할 수도 있지만, 실제로 내부 구현은 비슷합니다.
func main() {
var dst, src string
src = "this is a string"
dst = strings.Clone(src)
fmt.Println(src, dst)
}연결
문자열 연결은 + 연산자를 사용합니다.
func main() {
str := "this is a string"
str = str + " that is a int"
fmt.Println(str)
}바이트 슬라이스로 변환한 후 요소를 추가할 수도 있습니다.
func main() {
str := "this is a string"
bytes := []byte(str)
bytes = append(bytes, "that is a int"...)
str = string(bytes)
fmt.Println(str)
}위 두 가지 연결 방식은 성능이 매우 나쁩니다. 일반적으로 사용할 수 있지만, 더 높은 성능이 요구되는 경우 strings.Builder 를 사용할 수 있습니다.
func main() {
builder := strings.Builder{}
builder.WriteString("this is a string ")
builder.WriteString("that is a int")
fmt.Println(builder.String())
}this is a string that is a int순회
문서 시작 부분에서 언급했듯이, Go 의 문자열은 읽기 전용 바이트 슬라이스입니다. 즉, 문자열의 구성 단위는 바이트이며 문자가 아닙니다. 이러한 경우는 문자열을 순회할 때 자주 발생합니다. 예를 들어 아래 코드와 같습니다.
func main() {
str := "hello world!"
for i := 0; i < len(str); i++ {
fmt.Printf("%d,%x,%s\n", str[i], str[i], string(str[i]))
}
}예시에서는 바이트의 10 진수 형식과 16 진수 형식을 각각 출력했습니다.
104,68,h
101,65,e
108,6c,l
108,6c,l
111,6f,o
32,20,
119,77,w
111,6f,o
114,72,r
108,6c,l
100,64,d
33,21,!예시의 문자가 모두 ASCII 문자이므로 하나의 바이트로 표현할 수 있어 우연히 각 바이트가 하나의 문자에 해당합니다. 하지만 비 ASCII 문자가 포함되면 결과가 다릅니다. 예를 들어
func main() {
str := "hello 세계!"
for i := 0; i < len(str); i++ {
fmt.Printf("%d,%x,%s\n", str[i], str[i], string(str[i]))
}
}일반적으로 한자는 3 바이트를 차지하므로 다음과 같은 결과를 볼 수 있습니다.
104,68,h
101,65,e
108,6c,l
108,6c,l
111,6f,o
32,20,
228,e4,ä
184,b8,¸
150,96,
231,e7,ç
149,95,
140,8c,
33,21,!바이트 단위로 순회하면 한자가 분리되어 인코딩 오류가 발생할 수 있습니다. Go 문자열은 명시적으로 UTF-8 을 지원하므로, 이러한 경우 rune 타입을 사용해야 합니다. for range 를 사용하여 순회할 때 기본 순회 단위는 rune 입니다. 예를 들어 아래 코드와 같습니다.
func main() {
str := "hello 세계!"
for _, r := range str {
fmt.Printf("%d,%x,%s\n", r, r, string(r))
}
}출력은 다음과 같습니다.
104,68,h
101,65,e
108,6c,l
108,6c,l
111,6f,o
32,20,
19990,4e16,세
30028,754c,계
33,21,!rune 은 본질적으로 int32 타입의 별칭입니다. Unicode 문자 집합의 범위는 0x0000 - 0x10FFFF 사이이며, 최대 3 바이트만 필요합니다. 합법적인 UTF-8 인코딩의 최대 바이트 수는 4 바이트이므로 int32 를 사용하여 저장하는 것은 당연합니다. 위의 예시에서 문자열을 []rune 으로 변환한 후 순회하는 것도 같은 원리입니다. 예를 들어
func main() {
str := "hello 세계!"
runes := []rune(str)
for i := 0; i < len(runes); i++ {
fmt.Println(string(runes[i]))
}
}utf8 패키지의 도구를 사용할 수도 있습니다. 예를 들어
func main() {
str := "hello 세계!"
for i, w := 0, 0; i < len(str); i += w {
r, width := utf8.DecodeRuneInString(str[i:])
fmt.Println(string(r))
w = width
}
}이 두 예시의 출력은 모두 동일합니다.
TIP
문자열에 대한 더 많은 세부사항은 Strings, bytes, runes and characters in Go 에서 확인하실 수 있습니다.
