Skip to content

string

string 은 Go 에서 매우 흔한 기본 데이터 유형이며, Go 언어에서 처음 접한 데이터 유형이기도 합니다.

go
package main

import "fmt"

func main() {
  fmt.Println("hello,world!")
}

대부분의 사람이 Go 를 처음 접할 때 이 코드를 입력해 보았을 것입니다. builtin/builtin.go 에는 string 에 대한 간단한 설명이 있습니다.

go
// string is the set of all strings of 8-bit bytes, conventionally but not
// necessarily representing UTF-8-encoded text. A string may be empty, but
// not nil. Values of string type are immutable.
type string string

위 문장에서 다음 정보를 얻을 수 있습니다.

  • string 은 8 비트 바이트의 집합
  • string 유형은 일반적으로 UTF-8 인코딩
  • string 은 비어 있을 수 있지만 nil 은 아님
  • string 은 불변

이러한 특징은 Go 를 자주 사용하는 사람이라면 이미 잘 알고 있을 것입니다. 아래에서는 조금 다른 내용을 살펴보겠습니다.

구조

Go 에서 문자열은 런타임에 runtime.stringStruct 구조체로 표현되지만, 외부에 노출되지 않습니다. 대신 reflect.StringHeader 를 사용할 수 있습니다.

TIP

StringHeader 는 버전 go.1.21 에서 폐기되었지만, 매우 직관적이므로 아래 내용에서는 여전히 이를 사용하여 설명합니다. 이해에는 지장이 없으며, 자세한 내용은 Issues · golang/go (github.com) 를 참조하십시오.

go
// runtime/string.go
type stringStruct struct {
  str unsafe.Pointer
  len int
}

// reflect/value.go
type StringHeader struct {
  Data uintptr
  Len  int
}

필드 설명은 다음과 같습니다.

  • Data, 문자열 메모리 시작 주소를 가리키는 포인터
  • Len, 문자열의 바이트 수

다음은 unsafe 포인터를 사용하여 문자열 주소에 액세스하는 예시입니다.

go
func main() {
  str := "hello,world!"
  h := *((*reflect.StringHeader)(unsafe.Pointer(&str)))
  for i := 0; i < h.Len; i++ {
    fmt.Printf("%s ", string(*((*byte)(unsafe.Add(unsafe.Pointer(h.Data), uintptr(i)*unsafe.Sizeof(str[0]))))))
  }
}

하지만 Go 는 이제 unsafe.StringData 를 사용하는 것을 권장합니다.

go
func main() {
  str := "hello,world!"
  ptr := unsafe.Pointer(unsafe.StringData(str))
  for i := 0; i < len(str); i++ {
    fmt.Printf("%s ", string(*((*byte)(unsafe.Add(ptr, uintptr(i)*unsafe.Sizeof(str[0]))))))
  }
}

두 출력은 모두 동일합니다.

h e l l o , w o r l d !

문자열은 본질적으로 연속된 메모리 주소이며, 각 주소에는 바이트가 저장됩니다. 다시 말해 바이트 배열이며, len 함수를 통해 가져온 결과는 문자 수가 아닌 바이트 수입니다. 문자열의 문자가 ASCII 문자가 아닐 때 특히 그렇습니다.

string 자체는 실제 데이터를 가리키는 포인터만 차지하므로 문자열 전달 비용이 매우 낮습니다. 개인적으로 생각하기에, 메모리 참조만 보유하므로 임의로 수정할 수 있다면 원래 참조가 여전히 원하는 데이터인지 알기 어렵습니다 (리플렉션이나 unsafe 패키지 사용 필요). 또 다른 장점은 기본적으로 동시성 안전이라는 점이며, 어떤 사람도 일반적인 상황에서는 수정할 수 없습니다.

연결

문자열 연결 문법은 다음과 같으며, 직접 + 연산자를 사용하여 연결합니다.

go
var (
    hello = "hello"
    dot   = ","
    world = "world"
    last  = "!"
)
str := hello + dot + world + last

런타임에서 연결 작업은 runtime.concatstrings 함수에 의해 완료됩니다. 아래 코드와 같은 리터럴 연결인 경우, 컴파일러는 직접 결과를 추론합니다.

go
str := "hello" + "," + "world" + "!"
_ = str

어셈블리 코드를 출력하면 결과를 알 수 있으며, 일부는 다음과 같습니다.

LEAQ    go:string."hello,world!"(SB), AX
MOVQ    AX, main.str(SP)

컴파일러가 이를 완전한 문자열로 간주하며, 값은 컴파일 기간에 이미 결정되므로 런타임에 runtime.concatstrings 에 의해 연결되지 않습니다. 문자열 변수를 연결할 때만 런타임에 완료되며, 함수 시그니처는 다음과 같으며 바이트 배열과 문자열 슬라이스를 받습니다.

go
func concatstrings(buf *tmpBuf, a []string) string

연결할 문자열 변수가 5 개 미만일 때는 아래 함수로 대체됩니다 (개인 추측: 매개변수와 익명 변수 전달은 모두 스택에 존재하므로 런타임에 생성된 슬라이스보다 GC 에 더 좋음?). 마지막은 concatstrings 함수가 연결을 완료합니다.

go
func concatstring2(buf *tmpBuf, a0, a1 string) string {
  return concatstrings(buf, []string{a0, a1})
}

func concatstring3(buf *tmpBuf, a0, a1, a2 string) string {
  return concatstrings(buf, []string{a0, a1, a2})
}

func concatstring4(buf *tmpBuf, a0, a1, a2, a3 string) string {
  return concatstrings(buf, []string{a0, a1, a2, a3})
}

func concatstring5(buf *tmpBuf, a0, a1, a2, a3, a4 string) string {
  return concatstrings(buf, []string{a0, a1, a2, a3, a4})
}

아래에서는 concatstrings 함수 내부에서 무엇을 하는지 살펴보겠습니다.

go
func concatstrings(buf *tmpBuf, a []string) string {
  idx := 0
  l := 0
  count := 0
  for i, x := range a {
    n := len(x)
    // 길이가 0 이면 건너뜀
    if n == 0 {
      continue
    }
    // 수치 계산 오버플로우
    if l+n < l {
      throw("string concatenation too long")
    }
    l += n
    // 카운트
    count++
    idx = i
  }
  // 문자열이 없으면 빈 문자열 반환
  if count == 0 {
    return ""
  }

  // 문자열이 하나만 있으면 직접 반환
  if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
    return a[idx]
  }
  // 새 문자열을 위한 메모리 할당
  s, b := rawstringtmp(buf, l)
  for _, x := range a {
        // 복사
    copy(b, x)
        // 자르기
    b = b[len(x):]
  }
  return s
}

먼저 수행하는 작업은 연결할 문자열의 총 길이와 수를 통계한 후 총 길이에 따라 메모리를 할당합니다. rawstringtmp 함수는 문자열 s 와 바이트 슬라이스 b 를 반환하며, 길이는 확정되지만 내용은 없습니다. 이들은 본질적으로 새 메모리 주소를 가리키는 두 개의 포인터이기 때문입니다. 메모리 할당 코드는 다음과 같습니다.

go
func rawstring(size int) (s string, b []byte) {
    // 유형 지정 안 함
  p := mallocgc(uintptr(size), nil, false)
    // 메모리 할당했지만 내용은 없음
  return unsafe.String((*byte)(p), size), unsafe.Slice((*byte)(p), size)
}

반환된 문자열 s 는 표현을 위한 것이며, 바이트 슬라이스 b 는 문자열 수정을 위한 것입니다. 둘은 모두 동일한 메모리 주소를 가리킵니다.

go
for _, x := range a {
    // 복사
    copy(b, x)
    // 자르기
    b = b[len(x):]
}

copy 함수는 런타임에 runtime.slicecopy 를 호출하며, src 의 메모리를 dst 주소로 직접 복사합니다. 모든 문자열 복사完毕后 연결 과정이 끝납니다. 복사하는 문자열이 매우 크면 이 과정은 상당한 성능 소모가 발생합니다.

Golang by www.golangdev.cn edit