Programmer

Will Change The World

听说string内容不能修改?我偏要改给你看!

很多文章都说,Go语言中的string类型被设计为不可修改(immutable)的。的确,从Go语法的角度来说,确实是无法修改的。比如如下代码:

func main() {
	s := "hello "
	s += "world"
	s[0] = 'H'
	println(s)
}
// output:
./main.go:10:2: cannot assign to s[0] (value of type byte)

那么有没有什么办法突破这个限制呢?答案是有的。

很多文章都讲过通过unsafe.Pointer实现无内存复制将string和[]byte类型互转,那么可以将string类型转换为[]byte类型,然后[]byte类型是允许修改其内容的,因此就可以达到修改string的目的。

string类型在底层运行时(runtime)是使用一个struct来表示的,其中包含string的地址及长度。其定义近似如下:

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

这个结构与切片(slice)的结构相似,只是切片还多了个容量字段,如下:

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

只要[]byte和slice共用底层数据,那么通过更改[]byte内容即可达到更改string内容的目的。代码如下:

func main() {
	s := "hello "
	s += "world"
	//s[0] = 'H'
	//println(s)

	sh := *(*reflect.StringHeader)(unsafe.Pointer(&s))

	bh := reflect.SliceHeader{
		Data: sh.Data,
		Len:  sh.Len,
		Cap:  sh.Len,
	}

	bs := *(*[]byte)(unsafe.Pointer(&bh))

	bs[0] = 'H'
	println(s)
}

// output: 
Hello world

这就完了?那当然不是了。

不知道你有没有注意,上面的代码中,为什么要把两个字符串加起来呢?直接赋值一个完整的”hello world”不好吗?

来试试好了:

func main() {
	s := "hello world"

	sh := *(*reflect.StringHeader)(unsafe.Pointer(&s))

	bh := reflect.SliceHeader{
		Data: sh.Data,
		Len:  sh.Len,
		Cap:  sh.Len,
	}

	bs := *(*[]byte)(unsafe.Pointer(&bh))

	bs[0] = 'H'
	println(s)
}
// output:
unexpected fault address 0x106a41a
fatal error: fault
[signal SIGBUS: bus error code=0x2 addr=0x106a41a pc=0x105a13d]

goroutine 1 [running]:
runtime.throw({0x106983d?, 0x11384b0?})
        /Users/qinghe/sdk/go1.18beta1/src/runtime/panic.go:992 +0x71 fp=0xc00006cee8 sp=0xc00006ceb8 pc=0x102b391
runtime.sigpanic()
        /Users/qinghe/sdk/go1.18beta1/src/runtime/signal_unix.go:794 +0x1e5 fp=0xc00006cf38 sp=0xc00006cee8 pc=0x103eb85
main.main()
        /Users/qinghe/go/src/test/teststring/main.go:24 +0x5d fp=0xc00006cf80 sp=0xc00006cf38 pc=0x105a13d
runtime.main()
        /Users/qinghe/sdk/go1.18beta1/src/runtime/proc.go:255 +0x227 fp=0xc00006cfe0 sp=0xc00006cf80 pc=0x102da87
runtime.goexit()
        /Users/qinghe/sdk/go1.18beta1/src/runtime/asm_amd64.s:1571 +0x1 fp=0xc00006cfe8 sp=0xc00006cfe0 pc=0x1051f01
exit status 2

程序崩了!

为什么直接赋值一个完整的字面量不行,而做个”+”操作就可以了呢?

通过汇编来一探究竟: go tool compile -S main.go,得到汇编代码,摘取其中主要部分:

        0x0018 00024 (main.go:8)        FUNCDATA        $2, "".main.stkobj(SB)
        0x0018 00024 (main.go:9)        LEAQ    go.string."hello world"(SB), AX
        0x001f 00031 (main.go:9)        MOVQ    AX, "".s+40(SP)
        0x0024 00036 (main.go:9)        MOVQ    $11, "".s+48(SP)
        0x002d 00045 (main.go:14)       MOVQ    "".s+40(SP), AX
        0x0032 00050 (main.go:16)       MOVUPS  X15, "".bh+16(SP)
        0x0038 00056 (main.go:16)       MOVQ    $0, "".bh+32(SP)
        0x0041 00065 (main.go:17)       MOVQ    AX, "".bh+16(SP)
        0x0046 00070 (main.go:18)       MOVQ    $11, "".bh+24(SP)
        0x004f 00079 (main.go:19)       MOVQ    $11, "".bh+32(SP)
        0x0058 00088 (main.go:22)       MOVQ    "".bh+16(SP), AX
        0x005d 00093 (main.go:24)       MOVB    $72, (AX)
        0x0060 00096 (main.go:25)       PCDATA  $1, $1
        0x0060 00096 (main.go:25)       CALL    runtime.printlock(SB)
        0x0065 00101 (main.go:25)       MOVQ    "".s+40(SP), AX
        0x006a 00106 (main.go:25)       MOVQ    "".s+48(SP), BX
        0x006f 00111 (main.go:25)       PCDATA  $1, $0
        0x006f 00111 (main.go:25)       CALL    runtime.printstring(SB)
        0x0074 00116 (main.go:25)       CALL    runtime.printnl(SB)
        0x0079 00121 (main.go:25)       CALL    runtime.printunlock(SB)
        .......
go.cuinfo.packagename. SDWARFCUINFO dupok size=0
        0x0000 6d 61 69 6e                                      main
""..inittask SNOPTRDATA size=32
        0x0000 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00  ................
        0x0010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
        rel 24+8 t=1 reflect..inittask+0
go.string."hello world" SRODATA dupok size=11
        0x0000 68 65 6c 6c 6f 20 77 6f 72 6c 64                 hello world
runtime.gcbits.01 SRODATA dupok size=1
        0x0000 01                                               .

可以看到,”hello world”是通过符号(symbol) go.string.”hello world”来表示的,而在下面可以看到这部分数据有SRODATA属性的,标识这部分数据是存储在内存的只读区域,因此,如果要对这部分数据进行修改的话,自然是会造成崩溃的。

而前面的通过”+”操作进行赋值的变量,因为合并两个字符串,需要重新申请内存,然后复制旧的字符串至新的内存地址。这部分内存是要通过逃逸分析来判断是分配在堆上还是栈上的,但是无论是在堆上还是在栈上,都不是只读的,也并未通过系统调用设置只读属性,因此可以对它进行修改是没有问题的。

总结

通过字面量方式赋值的字符串变量,因为其底层数据是存储在内存只读区域的,因此无法进行修改。通过其他操作引起底层内存重新分配的字符串变量,可以转换为[]byte后再进行修改

点赞

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注