string
string は Go で非常に一般的な基本データ型で、私が Go 言語で最初に接触したデータ型でもあります。
package main
import "fmt"
func main() {
fmt.Println("hello,world!")
}このコードは、ほとんどの人が Go に触れた際に最初に打ったのではないでしょうか。builtin/builtin.go には string に関する簡単な説明があります。
// 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) を参照してください。
// runtime/string.go
type stringStruct struct {
str unsafe.Pointer
len int
}
// reflect/value.go
type StringHeader struct {
Data uintptr
Len int
}フィールドの意味は以下の通りです。
Data:文字列メモリの開始アドレスへのポインタLen:文字列のバイト数
以下は unsafe ポインタで文字列アドレスにアクセスする例です。
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 の使用を推奨しています。
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 !
文字列の本質は連続したメモリ領域で、各アドレスに 1 バイトが格納されています。言い換えればバイト配列であり、len 関数で取得できるのは文字数ではなくバイト数です。文字列に ASCII 文字以外の文字が含まれている場合は特に注意が必要です。
string 自体は実際のデータを指すポインタのみを占めるため、文字列を渡すコストは非常に低くなります。メモリ参照のみを保持するため、自由に修改できる場合、元の参照がまだ意図したデータかどうかを知るのが難しくなります(リフレクションまたは unsafe パッケージを使用する必要がある)。もう一つの利点は、並行安全であることです。通常、誰も変更できません。
連結

文字列の連結構文は以下の通りで、+ 演算子を使用して直接連結します。
var (
hello = "hello"
dot = ","
world = "world"
last = "!"
)
str := hello + dot + world + last連結操作は実行時に runtime.concatstrings 関数によって完了します。以下のリテラル連結の場合、コンパイラは直接結果を推論します。
str := "hello" + "," + "world" + "!"
_ = strアセンブリコードを出力すると結果がわかります。一部は以下の通りです。
LEAQ go:string."hello,world!"(SB), AX
MOVQ AX, main.str(SP)明らかに、コンパイラはこれを 1 つの完全な文字列として扱い、その値はコンパイル時に決定され、実行時に runtime.concatstrings によって連結されることはありません。文字列変数の連結のみが実行時に完了します。関数シグネチャは以下の通りで、バイト配列と文字列スライスを受け取ります。
func concatstrings(buf *tmpBuf, a []string) string連結する文字列変数が 5 つ未満の場合、以下の関数が代わりに使用されます(推測:パラメータと匿名変数の渡し方はスタック上に存在し、実行時に作成されるスライスよりも GC しやすい?)。最終的には concatstrings が連結を完了します。
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 関数内で行われていることを見てみましょう。
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 ""
}
// 文字列が 1 つだけなら直接返す
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 を返します。長さは確定していますが、内容は空です。これらは本質的に新しいメモリアドレスを指す 2 つのポインタだからです。メモリ割り当てのコードは以下の通りです。
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 は文字列を変更しやすくするためのものです。これらは同じメモリアドレスを指しています。
for _, x := range a {
// コピー
copy(b, x)
// 切り詰め
b = b[len(x):]
}copy 関数は実行時に runtime.slicecopy を呼び出し、src のメモリを dst のアドレスに直接コピーします。すべての文字列がコピー完毕后、連結プロセスが完了します。コピーする文字列が非常に大きい場合、このプロセスは相当なパフォーマンスを消費します。
変換
前述の通り、文字列自体は変更できません。修改を試みるとコンパイルすら通過せず、Go は以下のようにエラーを報告します。
str := "hello" + "," + "world" + "!"
str[0] = '1'cannot assign to string (neither addressable nor a map index expression)文字列を修改するには、まずバイトスライス []byte に型変換する必要があります。使用は非常に簡単です。
bs := []byte(str)内部では runtime.stringtoslicebyte 関数を呼び出します。ロジックは非常にシンプルです。
func stringtoslicebyte(buf *tmpBuf, s string) []byte {
var b []byte
if buf != nil && len(s) <= len(buf) {
*buf = tmpBuf{}
b = buf[:len(s)]
} else {
b = rawbyteslice(len(s))
}
copy(b, s)
return b
}文字列長がバッファ長より小さい場合は、バッファのバイトスライスを直接返します。これにより、小文字列変換時にメモリを節約できます。そうでない場合は、文字列長に相当するメモリを確保し、文字列を新しいメモリアドレスにコピーします。rawbyteslice(len(s)) 関数は以前の rawstring 関数と同様のことをし、メモリを割り当てます。
同様に、バイトスライスは構文的に簡単に文字列に変換できます。
str := string([]byte{'h','e','l','l','o'})内部では runtime.slicebytetostring 関数を呼び出します。理解しやすいです。
func slicebytetostring(buf *tmpBuf, ptr *byte, n int) string {
if n == 0 {
return ""
}
if n == 1 {
p := unsafe.Pointer(&staticuint64s[*ptr])
if goarch.BigEndian {
p = add(p, 7)
}
return unsafe.String((*byte)(p), 1)
}
var p unsafe.Pointer
if buf != nil && n <= len(buf) {
p = unsafe.Pointer(buf)
} else {
p = mallocgc(uintptr(n), nil, false)
}
memmove(p, unsafe.Pointer(ptr), uintptr(n))
return unsafe.String((*byte)(p), n)
}まずスライス長が 0 と 1 の特殊ケースを処理します。この場合、メモリコピーは不要です。次に、バッファ長より小さい場合はバッファのメモリを使用し、そうでない場合は新しいメモリを確保し、最後に memmove 関数でメモリを直接コピーします。コピー後のメモリは元メモリとは無関係なので、自由に変更できます。
值得注意的是、上記 2 つの変換方法はすべてメモリコピーを必要とし、コピーするメモリが非常に大きい場合、パフォーマンス消費も大きくなります。Go 1.20 バージョンに更新されると、unsafe パッケージに以下の関数が追加されました。
// メモリアドレスへの型ポインタとデータ長を渡し、スライス表現を返す
func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType
// スライスを渡し、底层配列へのポインタを取得
func SliceData(slice []ArbitraryType) *ArbitraryType
// アドレスと長さに基づき、文字列を返す
func String(ptr *byte, len IntegerType) string
// 文字列を渡し、開始メモリアドレスを取得。ただし返されるバイトは修改できない
func StringData(str string) *byte特に String と StringData 関数は、メモリコピーを含まず変換を完了できます。ただし、データが読み取り専用であり、後続の修改がないことを確認する必要があります。否则文字列が変化する可能性があります。以下の例を見てください。
func main() {
bs := []byte("hello,world!")
s := unsafe.String((*byte)(unsafe.SliceData(bs)), len(bs))
bs[0] = 'b'
fmt.Println(s)
}まず SliceData でバイトスライスの底层配列アドレスを取得し、String で文字列表現を取得します。その後バイトスライスを直接修改すると、文字列も変化します。これは文字列の本来の意図に反します。別の例を見てみましょう。
func main() {
str := "hello,world!"
bytes := unsafe.Slice(unsafe.StringData(str), len(str))
fmt.Println(bytes)
// fatal
bytes[0] = 'b'
fmt.Println(str)
}文字列のスライス表現を取得した後、バイトスライスの修改を試みると、直接 fatal します。では、文字列の宣言方法を変えて違いを見てみましょう。
func main() {
var str string
fmt.Scanln(&str)
bytes := unsafe.Slice(unsafe.StringData(str), len(str))
fmt.Println(bytes)
bytes[0] = 'b'
fmt.Println(str)
}hello,world!
[104 101 108 108 111 44 119 111 114 108 100 33]
bello,world!結果から、修改に成功したことがわかります。以前 fatal したのは、変数 str が文字列リテラルを格納しており、文字列リテラルは読み取り専用データセグメントに格納され、ヒープスタックではないため、リテラル宣言の文字列が後続で修改される可能性を根本的に断っています。通常の文字列変数の場合、本質的には修改可能ですが、この書き方はコンパイラが許可しません。总之、unsafe 関数を使用して文字列変換を操作するのは安全ではありません。データを変更しないことを保証できない限りです。
走査
s := "hello world!"
for i, r := range s {
fmt.Println(i, r)
}マルチバイト文字のケースを処理するため、文字列の走査には通常 for range ループを使用します。for range で文字列を走査する際、コンパイラはコンパイル時に以下の形式のコードに展開します。
ha := s
for hv1 := 0; hv1 < len(ha); {
hv1t := hv1
hv2 := rune(ha[hv1])
// 単バイト文字か判断
if hv2 < utf8.RuneSelf {
hv1++
} else {
hv2, hv1 = decoderune(ha, hv1)
}
i, r = hv1t, hv2
// 循環体
}展開されたコードでは、for range ループは古典的な for ループに置き換えられます。ループ内では、現在のバイトが単バイト文字か判断し、マルチバイト文字の場合は実行時関数 runtime.decoderune を呼び出して完全なエンコーディングを取得し、その後 i、r に代入します。処理完了後、ソースコードで定義されたループ本体が実行されます。
中間コードを構築する作業は、cmd/compile/internal/walk/range.go の walkRange 関数によって完了します。同時に、for range で走査可能なすべての型を処理しますが、ここでは展開しません。興味があれば自分で調べてみてください。
