Go语言中的字符串

本文翻译自Rob Pike的文章《Strings, bytes, runes and characters in Go》,原文地址 https://blog.golang.org/strings

原博文中出了一些练习,译者提出了自己的解法,原文中并不包含这些代码。

图文无关

前言

上一篇博客介绍了Go语言中的切片,这篇博客将会聊一聊Go语言中的字符串。字符串可能看起来很简单,但是要想使用好它,不仅需要了解它们的工作方式,还要了解字节,字符和符文之间的区别,Unicode和UTF-8以及其他一些更细微的区别。

经常会有人问到这个问题:“当我在位置n处索引Go字符串时,为什么不能获得第n个字符呢?”这个问题涉及到现代世界中处理文本的方式,我们将会带着这个问题继续下面的讨论。

什么是字符串?

在Go语言中,字符串实际上是类型为byte的只读切片。

一个字符串包含了任意个byte。它并不限定Unicode,UTF-8或者任何其他预定义的编码。在内容上,它完全等价于一个类型为byte的切片。

下面是一个字符串变量,它使用\xNN这样的形式来表示一些特殊的byte。

const sample = "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98"

打印字符串

在上面的例子中,有一些byte并不是ASCII码,甚至也不是UTF-8编码,直接打印出来会出现乱码

fmt.Println(sample)

// ��=� ⌘  
// 这个结果和程序执行的环境有关

要了解这个字符串究竟是什么,我们需要把它分开来检查一下。正如上面提到的,索引一个字符串,得到的是byte,而不是字符。

使用for循环遍历所有的字节。

for i := 0; i <len(sample); i++ {
    fmt.Printf("%x ", sample[i])
}

// bd b2 3d bc 20 e2 8c 98

下面一种更简单的打印方式。

fmt.Printf("%x\n", sample)

// bdb23dbc20e28c98

还有一个打印的小技巧。

fmt.Printf("% x\n", sample)

// bd b2 3d bc 20 e2 8c 98

还有一些小技巧。使用q标识,可以将字符串中任何不可打印的字节序列转义。

fmt.Printf("%q\n", sample)

// \xbd\xb2=\xbc ⌘

仔细观察,在一堆乱码中我们可以看到一个'='号和一个空格符号,最后面是瑞典表示“名胜古迹”的符号,这个符号的Unicode值为U+2318,UTF-8编码为"e28c98"。

如果不熟悉字符串中奇怪的字符,可以在打印的时候使用"+q"标识,这个标识采用UTF-8编码,它不仅会转义不可打印的字节序列,还会转义非ASCII码的字节。下面的代码将字符串中非ASCII数据对应的Unicode值打印出来。字符串中的瑞典字符作为Unicode字符,打印出来带有\u的转义符。

fmt.Printf("%+q\n", sample)

// "\xbd\xb2=\xbc \u2318"

这些打印的小技巧在调试字符串内容的时候很有用,并且会方便下面的讨论。值得注意的是,上面的小技巧对于byte的切片一样适用。

下面是一份完整的代码

package main

import "fmt"

func main() {
    const sample =  "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98"
    
    fmt.Println("Println:")
    fmt.Println(sample)
    
    fmt.Println("Byte loop:")
    for i:=0; i <len(sample); i++{
        fmt.Printf("%x ", sample[i])
    }
    fmt.Printf("\n")
    
    fmt.Println("Printf with %x:")
    fmt.Printf("%x\n", sample)
    
    fmt.Println("Printf with % x:")
    fmt.Printf("% x\n", sample)
    
    fmt.Println("Printf with %q:")
    fmt.Printf("%q\n", sample)
    
    fmt.Println("Printf with %+q:")
    fmt.Printf("%+q\n", sample)
}


// Println:
// ��=� ⌘
// Byte loop:
// bd b2 3d bc 20 e2 8c 98 
// Printf with %x:
// bdb23dbc20e28c98
// Printf with % x:
// bd b2 3d bc 20 e2 8c 98
// Printf with %q:
// "\xbd\xb2=\xbc ⌘"
// Printf with %+q:
// "\xbd\xb2=\xbc \u2318"

练习一

修改上面的代码,用byte切片替换字符串

package main

import "fmt"

func main() {
    sample := []byte("\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98")

    fmt.Println("Println:")
    fmt.Println(sample)

    fmt.Println("Byte loop:")
    for i := 0; i < len(sample); i++ {
        fmt.Printf("%x ", sample[i])
    }
    fmt.Printf("\n")

    fmt.Println("Printf with %x:")
    fmt.Printf("%x\n", sample)

    fmt.Println("Printf with % x:")
    fmt.Printf("% x\n", sample)

    fmt.Println("Printf with %q:")
    fmt.Printf("%q\n", sample)

    fmt.Println("Printf with %+q:")
    fmt.Printf("%+q\n", sample)
}

// Println:
// [189 178 61 188 32 226 140 152]
// Byte loop:
// bd b2 3d bc 20 e2 8c 98 
// Printf with %x:
// bdb23dbc20e28c98
// Printf with % x:
// bd b2 3d bc 20 e2 8c 98
// Printf with %q:
// "\xbd\xb2=\xbc ⌘"
// Printf with %+q:
// "\xbd\xb2=\xbc \u2318"

练习二

对字符串中的每个字节用%q方式打印

func main() {
    const sample = "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98"

    fmt.Println("Println:")
    fmt.Println(sample)

    fmt.Println("Byte loop:")
    for i := 0; i < len(sample); i++ {
        fmt.Printf("%q ", sample[i])
    }
}

// Println:
// ��=� ⌘
// Byte loop:
// '½' '²' '=' '¼' ' ' 'â' '\u008c' '\u0098' 

UTF-8和字符串

正如我们看到的,索引一个字符串返回的是byte,而不是字符:一个字符串就是一堆字节。这意味着,当我们将字符存储在字符串中时,实际存储的是这个字符的字节。来看一个例子。

下面的程序用三种不同的方式打印一个字符串常量,一次作为纯字符串,一次作为只有ASCII的字符串,一次作为16进制的字节。为了避免混淆,创建一个“原始字符串”,用反括号括起来,因此它只包含文本。

func main() {
    const placeOfInterest = `⌘`
    
    fmt.Printf("plain string: ")
    fmt.Printf("%s", placeOfInterest)
    fmt.Printf("\n")
    
    fmt.Printf("quoted string: ")
    fmt.Printf("%+q", placeOfInterest)
    fmt.Printf("\n")
    
    fmt.Printf("hex bytes: ")
    for i := 0; i < len(placeOfInterest); i++ {
        fmt.Printf("%x ", placeOfInterest[i])
    }
    fmt.Printf("\n")
}

// plain string: ⌘
// quoted string: "\u2318"
// hex bytes: e2 8c 98 

上面的例子告诉我们,Unicode字符值U+2318,名胜古迹符号"⌘"的字节表示为"e2 8c 98",这些字节是16进制值"2318"的UTF-8编码。

对这个例子的理解程度和你对UTF-8的熟悉程度有关,但是花一点时间来了解这个字符串的UTF-8表示形式的创建过程是值得的。简单的事实是,它是在源代码编写时创建的。

Go语言中的源代码定义为UTF-8文本,不允许其他的表示。也就是说,在代码中写下字符

`⌘`

时,用于创建程序的文本编辑器将符号的UTF-8编码放入源文本中。当打印16进制字节时,我们只是将文件中的数据打印出来。

简单来说,Go源码是UTF-8,因此字符串文字的源代码是UTF-8文本。如果该字符串文字不包含转义序列,构造的字符串将精确保存引号之间的源文本。因此,通过定义和构造,原始字符串将总是包含其内容的有效UTF-8表示。类似的,除非它包含类似上面例子中的UTF-8拆分转义,否则常规字符串字面值也将始终包含有效的UTF-8。

有些人认为Go字符串总是UTF-8,但是不是这样:只有字符串字面量是UTF-8。正如上面所示,字符串值可以包含任意字节,只要没有字节级转义,字符串文字总是包含UTF-8文本。

总而言之,字符串可以包含任意字节,但是当从字符串文字构造时,这些字节(几乎总是)是UTF-8。

字节点,字符和rune

我们一直非常小心地使用“字节”和“字符”这样说法。这部分是因为字符串中保存了字节,还有部分是因为“字符”的概念有点难以定义。Unicode标准使用术语“代码点”来用单个值表示一个字符。比如具有16进制值2318的代码点,U+2318表示符号⌘。

举一个更普遍的例子,Unicode代码点U+0061表示小写拉丁字母'A':a。但是小写的重音字母'A': à怎么表示呢?这是一个字符,它可以用代码点U+00E0表示,但也有其它表示。例如,可以“组合”严重重音代码点U+0300,并将其附加到小写字母a,U+0061,以创建字符à。通常情况下,字符可以由多个不同的代码点序列表示,由此可以用不同的UTF-8字节序列表示。

因此,计算中的字符概念是模糊的,至少是混乱的,所以我们需要谨慎地使用它。有规范化的技术来保证字符和代码点的映射,不过这个领域和本文讨论的内容相去甚远。

“代码点”这样的说法有点绕口,所以Go语言介绍了一个较短的术语概念:rune,符文。该术语出现字库和源代码中,和“代码点”表达的意思完全相同。还有一个有趣的补充。

Go语言将rune定义为int32类型的别名,因此在使用一个整型值表达一个“代码点”时,代码更加清晰。此外,一个你可能认为是字符常数在Go中是符文常数。'⌘'的类型和值分别是rune和0x2318。

总结一些前面的内容:

  • Go源码总是UTF-8
  • 一个字符串包含任意个byte
  • 字符串常量如果缺少字节级转义,将始终保持UTF-8序列。
  • Unicode的代码点在Go语言中被称为rune
  • Go不保证字符串中的字符是规范化的。

range循环

除了Go源码是UTF-8外,Go只提供一种方法特殊处理UTF-8,那就是在字符串上使用for range循环。我们已经知道使用for循环会发生什么了。

而一个range循环会在每次迭代时,解码一个UTF-8编码的符文。每次循环时,循环的索引是当前文字的起始位置,以字节为单位,代码点是它的值。

下面是一个使用%#U格式化的例子,它显示了代码点的Unicode值及其打印的表现形式。

const nihongo = "日本語"
for index, runeValue := range nihongo {
    fmt.Printf("%#U starts at byte position %d\n", runeValue, index)
}

// U+65E5 '日' starts at byte position 0
// U+672C '本' starts at byte position 3
// U+8A9E '語' starts at byte position 6

练习

在字符串中插入不合法的UTF-8字节序列。观察会发生什么

const nihongo = "日本\xbd語"
for index, runeValue := range nihongo {
    fmt.Printf("%#U starts at byte position %d\n", runeValue, index)
}

// U+65E5 '日' starts at byte position 0
// U+672C '本' starts at byte position 3
// U+FFFD '�' starts at byte position 6
// U+8A9E '語' starts at byte position 7

Go语言标准库强有力地支持解码UTF-8文本。如果for range循环不能满足你的需求,那就需要一些别的包来解决问题。

最重要的库为unicode/utf8,它包含帮助程序例程来验证,反汇编和重组UTF-8字符串。下面的例子功能和上面for range实现一样的功能,不同的是使用了DecodeRuneInString函数。

const nihongo = "日本語"
for i, w := 0, 0; i < len(nihongo); i += w {
    runeValue, width := utf8.DecodeRuneInString(nihongo[i:])
    fmt.Printf("%#U starts at byte position %d\n", runeValue, i)
    w = width
}

// U+65E5 '日' starts at byte position 0
// U+672C '本' starts at byte position 3
// U+8A9E '語' starts at byte position 6

总结

回答文章刚开始时提出的问题:字符串是由字节构建的,所以索引它们返回字节,而不是字符。字符串甚至可能不包含字符。事实上,“字符”的定义是不明确的,尝试通过定义字符串由字符组成来解决歧义是一个错误。

关于Unicode,UTF-8和多语言文本处理,还有很多可以聊的。现在,我们希望您已经更好地了解Go字符串,尽管它们可能包含任意字节,但UTF-8是其设计的核心。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容

  • 字符串在 Go 语言中以原生数据类型出现,使用字符串就像使用其他原生数据类型(int、bool、float32、f...
    DreamKing阅读 2,719评论 0 0
  • 字符集和编码简介 在编程中常常可以见到各种字符集和编码,包括ASCII,MBCS,Unicode等字符集。确切的说...
    兰山小亭阅读 8,456评论 0 13
  • 字符串和字符 甲串是一系列字符,如的"hello, world"或"albatross"。Swift字符串由Str...
    Fuuqiu阅读 1,028评论 0 0
  • 说到健身,可能你也会去健身房,大部分情况下,你的健身教练都会给你设计一个方案。这个方案是什么呢?一个就是举铁,也就...
    虞在乌伤阅读 178评论 0 1
  • 细雨柔柔,素手采红豆,霜叶送扁舟,莫问相思谁遥寄,三生石畔影双幽。
    dengchunliu阅读 120评论 0 0