电报Telegram使用手机号注册成功后,默认不隐藏你的手机号,你的手机号会被Telegram上面的其他人看到,很不安全。
如何在Telegram里隐藏手机号,网上教程有很多,搜索一下。
最后记得重启Telegram,隐藏手机号的设置才会生效!
电报Telegram使用手机号注册成功后,默认不隐藏你的手机号,你的手机号会被Telegram上面的其他人看到,很不安全。
如何在Telegram里隐藏手机号,网上教程有很多,搜索一下。
最后记得重启Telegram,隐藏手机号的设置才会生效!
本文翻译自《Go Data Structures》。
2009/11/24
在向新程序员解释Go程序时,我发现解释Go值在内存中的样子通常有助于建立正确的直觉,了解哪些操作是昂贵的,哪些不昂贵。这篇文章是关于Go的基本类型、结构体、数组和切片的。
基本类型
让我们从一些简单的例子开始:
变量i
的类型为int,在内存中表示为一个32位的字(word)。(所有这些图片都显示了32位内存布局;在当前的实现中,只有指针在64位机器上变长了——int仍然是32位——尽管可以选择使用64位的int64类型。)
由于显式转换,变量j
的类型为int32。即使i
和j
有相同的内存布局,它们也有不同的类型:赋值i=j
会引起一个类型错误,必须使用显式转换:i=int(j)
。
变量f
的类型为float,当前实现将其表示为32位浮点值。它具有与int32相同的内存占用空间,但内部布局不同。
结构体及其指针
现在情况开始变得有趣。变量bytes
的类型为[5]byte,是一个由5个字节组成的数组。它的内存表示就是这5个字节,一个接一个,就像一个C数组。类似地,primes
是一个由4个int组成的数组。
Go与C一样,但与Java不一样,它可以让程序员控制什么是指针,什么不是指针。例如,此类型定义:
type Point struct { X, Y int }
定义了一个名为Point
的简单结构体类型,表现在内存中就是两个相邻的int字段。
复合字面量语法Point{10, 20}
表示已初始化的一个Point
实例。获取Point{10, 20}
的地址&Point{10, 20}
表示指向Point{10, 20}
的指针。前者是内存中的两个字(word);后者是指向内存中这两个字的指针。
结构体中的字段在内存中并排排列。
type Rect1 struct { Min, Max Point }
type Rect2 struct { Min, Max *Point }
Rect1
是一个具有两个Point
字段的结构体,在内存中由一行中的两个Point
字段(四个int
)表示。Rect2
是一个具有两个*Point
字段的结构体。
使用过C语言的程序员可能不会对Point字段和*Point字段之间的区别感到惊讶,而只使用过Java或Python(或…)的程序员可能会感到惊讶。通过让程序员控制基本的内存布局,Go提供了控制给定数据结构集合的总大小、分配的元素的数量和内存访问模式的能力,所有这些对于构建性能良好的系统都很重要。
字符串
有了这些预备知识,我们可以继续研究更有趣的数据类型。
(灰色箭头表示字符串实现中存在但在程序中不直接可见的指针。)
一个字符串string在内存中表示为一个2字结构体,里面包含一个指向字符串数据(是一个字节数组)的指针和一个长度字段。由于字符串是不可变类型,因此多个字符串共享同一底层存储是安全的,因此对s进行切片会产生一个新的2字结构体,该结构体具有不同的指针和长度字段,但仍然引用相同的底层字节序列。这意味着可以在不重新分配或复制的情况下进行切片,从而使字符串切片与显式地使用下标索引一样高效。
(顺便说一句,Java和其他语言中有一个众所周知的难题,当你对一个字符串进行切片以保存一小段时,对原始字符串的引用会将整个原始字符串保留在内存中,即使只需要少量的字符串。Go也有这个难题。我们尝试过但拒绝了另一种选择,那就是让字符串切片变得如此昂贵——一次再分配和一个新副本——大多数程序都应该避开它。)
切片(slice)
切片是对某个数组的部分引用。在内存中,它是一个3字结构体,包含指向第一个数组元素的指针、切片的长度和容量。长度是x[i]
等索引操作的上限,而容量是x[i:j]
等切片操作的上限。
与对字符串进行切片一样,对数组进行切片不会产生新副本:它只会创建一个包含不同指针、长度和容量的新结构体。在本例中,一开始创建切片[]int{2,3,5,7,11}
在底层会创建一个包含五个值的新数组,然后设置切片x
的字段来描述该数组。但切片表达式x[1:3]
没有分配更多的数据:它只是创建一个新的切片头结构体,以引用相同的底层数组。在本例中,它的长度为2,即y[0]
,y[1]
是唯一有效的索引,但容量为4,即y[0:4]
是有效的切片表达式。(有关长度和容量以及切片使用方式的详细信息,请参阅Effective Go。)
因为切片是多字结构体,而不是指针,所以切片操作不需要分配内存,甚至不需要为切片头分配内存,因为切片头通常可以保存在栈上。这种切片的使用成本与在C语言中显式传递指针和长度对一样低。Go最初将切片表示为指向上述结构体的指针,但这样做意味着每个切片操作都会分配一个新的内存对象。即使使用快速的内存分配器,也会给垃圾收集器带来很多不必要的工作。不使用指针和分配内存使得切片足够便宜。
new和make
Go有两个数据结构创建函数:new
和make
。它们的区别在早期是一个常见的混淆点,但似乎很快就变得很自然了。基本区别是new(T)
返回一个*T
,Go程序可以隐式地解引用该指针(下图中的黑色指针),而make(T,args)
返回普通的T
,而不是指针。通常,T
内部有一些隐式指针(下图中的灰色指针)。new
返回一个指向值全是0的一块内存区域的指针(如下图所示),而make
返回一个复杂的结构体。
有一种方法可以将这两者统一起来,但这将是对C和C++传统的重大突破:定义make(*T)
返回一个指向新分配的T
的指针,这样当前的new(Point)
就可以被改写为make(*Point)
。我们尝试了几天,但认为这与人们对分配函数的期望太不一样了。
更多……
这已经有点长了。接口(interface)、映射(map)和通道(channel)将不得不等待将来的发布。
本文翻译自《You should blog even if you have no readers》,作者是开源的分布式实时大数据处理框架Apache Storm的作者。
Spencer Fry的《为什么企业家应该写作》是一篇很棒的文章。我想进一步补充一点,写作的好处是如此非凡,即使你没有读者(无论你是否是企业家),你也应该写博客。
我有50多份未完成的草稿。其中一些只是我和自己争论时写下的一些想法。它们中的大多数永远不会出版,但我从所有这些写作中获得了价值。
写作使你成为更好的读者
博客改变了我阅读别人文章的方式。
在努力寻找正确的方式来构建和展示我的帖子的过程中,我更加适应什么是好的论点,什么是坏的论点。我也变得更善于发现别人推理中的漏洞。
同时,在阅读时,我不太可能陷入以微弱的反驳证据而诋毁帖子的陷阱。在大多数帖子中,都可能有基于特殊案例的反驳。网络评论者喜欢指出这些。然而,这些特殊的案例错过了帖子的主旨。通过理解帖子论点背后的隐含背景,我从阅读中获得了更多的价值。
我也更了解优秀作家的风格。我在脑海中注意优秀作家表达自己想法的方式。我一直很喜欢Paul Graham的写作,但现在我真的很欣赏他组织自己的帖子的方式。他有一种很棒的能力,可以把你吸引到他的世界里,并向你展示他眼中的世界。通过阅读Bradford Cross的博客,我学到了很多关于优秀写作的知识;他的帖子有一个清晰的弧线,很好地利用简短的段落来保持帖子的流畅性。
写作使你更加聪明
写作可以揭示你思维中的漏洞。当你的想法被写下来并回头看时,它们的说服力要比它们在你脑海中时低得多。写作迫使你通过思考和反驳来使你的想法更成熟。
写作可以帮助你以连贯的方式组织你的思想。当这些话题出现时,你就能侃侃而谈。我记不清有多少次我和其他人进行了更深层次的对话,因为我已经让自己的想法变得成熟。
把其他任何东西都视为附带收益
写作给你的其他一切东西——个人品牌、人际网络、工作机会——都只是附带的好处。这些好处有时候非常大,但它们不是你应该写作的主要原因。
你应该写作,因为写作会让你成为一个更好的人。
本文翻译自《Why entrepreneurs should write》。
Spencer Fry
2010/07/14
人们问我为什么要花时间写文章。我从制定和分享我的想法中得到了什么价值?我没有引入广告,所以不会在月底领到广告薪水——那为什么要这样做呢?我认为,作为企业家,把你的想法用语言表达出来可以帮助你思考自己的想法,帮助推销自己和产品,并带来良好的人际关系机会。但首先反驳一下大家对简讯(newsletter)的看法……
简讯
正如GigaOM的Mathew Ingram在一篇题为《是时候停止写博客并开始写电子邮件简讯?》的文章中所写的那样,一群人——主要是纽约市的企业家社区——已经从写博客转向写简讯。Jason Calacanis是2008年第一个这样做的人,他说这是一种解决他收到的辱骂言论的方式。通过简讯,人们可以直接向他发表评论,而不是向所有阅读他的帖子的人发表评论。问题解决了。
直到最近,Sam Lessin关闭了他的博客,推出了Letter.ly,作为一项简讯服务,你可以对自己的内容收费,尽管价格不高。“所以,是的,旧的又变成了新的,”Sam写道。
但我不使用简讯!
信息是用来消费的。它应该是免费的。它旨在让尽可能多的人接触到,分享和讨论。一堵围绕信息内容的墙——无论是付费的还是其他的——注定要倒塌。你只需要看看Jason Calacanis,当他真的想发出自己的声音时,他会在博客上重新发布他的简讯。
这个简讯“运动”,如果你真的可以这么称呼它的话——只有少数人真正在做——我猜订阅的人更少,它已经成为了一个过渡阶段。我非常尊重Sam、Michael、David、Andrew和其他转而撰写简讯的人——他们都是我的朋友——但如果看到他们在这件事上坚持己见,我会感到惊讶。如果他们真的重视写作的意义,那么他们将再次公开分享自己的内容。
博客已死?不全是
我认为博客在某种程度上已经死了。我不认为我的个人网站spencerfry.com是一个博客。这是一本散文集。传统意义上的博客——你对X、Y和Z的想法的片段——已经被推特、脸书和汤博乐所取代。几乎任何大小的段落都可以压缩到140个字符。
如果人们要坐下来阅读你要说的话,那么你必须认真写出一些值得阅读的内容。这年头信息爆炸,如果你想让别人阅读你的文章,那么你必须选择一个有趣的话题,深思熟虑地阐述你的想法,并支持你的论点。所有这些都需要不止140个字符,如果做得好,确实值得一读。
思考想法
大多数时候,当我坐下来写一篇文章时,我的脑海里都没有一个清晰的画面,我要说什么。只要我有一个我想谈论的话题和立场,总有回旋的余地让我形成自己的想法。把所有的东西都写下来有助于我完成思考的过程,并让我对某个话题做出有力的决定。如果它是书面的形式,那么当我点击“提交”时,我必须100%支持它。
通常先思考我将要写什么内容。然后以一个标题(尽管这通常会改变)、各个章节的标题以及每个标题下的一些潦草的想法开始。接下来,我开始写一些简介(在每个标题下面),最后把每一章节下面的段落都整理出来。
当我写完一篇文章时,我已经彻底地审视了这个话题的各个角度,做了研究,找到了一些外部证据,并在谷歌搜索栏上搜索了关于这个话题的相关材料。这个过程有助于巩固我的思考,像这样系统地充实你的想法的过程本就对你有所帮助。
教会你的读者
写下你的想法不仅有助于你形成自己的想法,而且教育读者也是一件非常值得做的事情。《通过教育促进真实生活》一书中指出,“你可以回馈支持你的社区,同时获得一些不错的宣传曝光率。”
俗话说,分享就是关爱。通过你的产品和你的名字,你的文章能得到进一步讨论的评论,你会收到读者的电子邮件,以及你遇到的那些读过你的文章的读者。我每次都很快乐。
网络(在线)
我在2010年4月写的一篇题为《如何建立网络》的文章给出了一些关于如何成功地在线下建立网络的基本技巧,但对于一个想要为自己和产品扬名立万的企业家来说,在线网络同样重要。
写一篇深思熟虑的文章意味着你有140个字之外的话要说,赢得你的尊重,并让你接触到有趣的人。我写作的许多粉丝都成了Carbonmade的粉丝。我们甚至被推荐为合作伙伴,成为了朋友。
推特、脸书、汤博乐等等都值得你花时间,但我发现没有什么比深思熟虑的散文集更能吸引大批粉丝了。如果你想从人群中脱颖而出,你首先必须在地面上划出一条线,表明你在什么问题上的什么立场。
首先更新系统:
sudo apt update && sudo apt upgrade -y
Ondrej sury PPA仓库是目前维护PHP最新版本的仓库,我们添加这个仓库:
sudo add-apt-repository ppa:ondrej/php
再次更新系统以使添加的Ondrej sury PPA仓库生效:
sudo apt update && sudo apt upgrade -y
安装php8.2:
sudo apt install php8.2 -y
安装完成后,检查php的版本:
php --version
使用sudo apt-get install php8.2-PACKAGE_NAME命令安装php常用扩展,把PACKAGE_NAME替换为具体的扩展名:
sudo apt-get install -y php8.2-cli php8.2-common php8.2-fpm php8.2-mysql php8.2-zip php8.2-gd php8.2-mbstring php8.2-curl php8.2-xml php8.2-bcmath
查看已经安装了哪些php扩展:
php -m
参考
本文翻译自《Arrays, slices (and strings): The mechanics of ‘append’》。
Rob Pike
2013/09/26
介绍
面向过程的编程语言的最常见的特性之一是数组的概念。数组看起来很简单,但在将它们添加到编程语言中时,必须回答许多问题,例如:
这些问题的答案会决定数组是编程语言的一个特性还是其设计的核心部分。
在Go的早期开发中,大约花了一年时间来决定这些问题的答案,然后才觉得设计是正确的。关键的一步是引入切片(slice),它建立在固定大小的数组上,以提供灵活的、可扩展的数据结构。然而,时至今日,刚接触Go的程序员经常会在切片的工作方式上磕磕碰碰,也许是因为其他语言的经验影响了他们的思维。
在这篇文章中,我们将试图消除混乱。我们将通过构建代码片段的方式来解释内置函数append是如何工作的,以及为什么它会这样工作。
数组
数组是Go中的一个重要的组成元素,但与建筑的基础一样,它通常隐藏在更显眼的组件下面。在我们继续讨论切片这个更有趣、更强大、更突出的概念之前,我们必须先简要地讨论一下数组。
数组在Go程序中并不常见,因为数组的大小是其类型的一部分,这限制了它的表达能力。
声明
var buffer [256]byte
声明了buffer
变量,是一个数组,可容纳256个字节。buffer
的类型包括其大小[256]byte
。而具有512个字节长度的数组将是不同的类型:[512]byte
。
与数组相关联的数据就是数组的元素们。从以下示意图来看,我们的buffer
数组在内存中是这样的,
buffer: byte byte byte ... 256 times ... byte byte byte
也就是说,该变量包含256字节的数据。我们可以通过buffer[255]
使用熟悉的索引语法:buffer[0]
、buffer[1]
等来访问它的元素。这里的buffer
数组的索引范围是0到255,包含256个元素。试图用超出此范围的值对buffer
数组进行索引将导致程序崩溃。
有一个名为len
的内置函数,它返回数组或切片以及其他一些数据类型的元素数量。对于数组,len
返回的内容是显而易见的。在我们的示例中,len(buffer)
返回固定值256。
数组有自己的作用——例如,它们可以很好地表示矩阵——但它们在Go中最常见的用途是作为切片的底层存储。
切片
想使用好切片,必须准确地了解它们是什么以及它们的作用。
切片是一种数据结构,描述与切片变量本身分开存储的数组的连续部分。切片不是数组。切片描述某一个数组的一部分。
对于上一节中的buffer
数组,我们可以通过对该数组进行切片来创建元素下标100到150(准确地说,是100到149,包括100到149)的切片:
var slice []byte = buffer[100:150]
在该代码段中,我们使用了显式的完整的变量声明:变量slice
的类型为[]byte
,发音为“slice of bytes”,通过对buffer
数组元素从下标100(包含)到150(不包含)进行切片,来初始化。更惯用的语法是不写出切片的类型:
var slice = buffer[100:150]
在函数体中,我们也可以使用海象运算符来初始化一个切片:
slice := buffer[100:150]
切片变量究竟是什么?虽然这还不是全貌,但现在可以将切片视为一个包含两个元素的小数据结构:长度和指向数组元素的指针。你可以把它想象成是在底层构造的如下所示的结构:
type sliceHeader struct {
Length int
ZerothElement *byte
}
slice := sliceHeader{
Length: 50,
ZerothElement: &buffer[100],
}
当然,这只是一个例子。尽管这个sliceHeader
结构对程序员来说是不可见的,并且元素指针的类型取决于元素的类型,但这给出了切片底层机制的一般性概念。
到目前为止,我们已经对数组使用了切片操作,但我们也可以对切片进行切片,如下所示:
slice2 := slice[5:10]
与之前一样,此操作创建一个新的切片,具有原始切片的下标从5到9(包含9)的元素,这意味着这个新切片具有原始数组的下标从105到109的元素。slice2
变量的底层sliceHeader
结构如下所示:
slice2 := sliceHeader{
Length: 5,
ZerothElement: &buffer[105],
}
请注意,此结构的指针仍然指向存储在buffer
变量中的底层数组。
我们也可以重新切片(再切片,reslice),也就是说对切片进行切片:
slice = slice[5:10]
这个slice
变量的sliceHeader
结构与slice2
变量的结构类似。你将经常使用重新切片,例如截断一个切片。以下这行代码截除切片的第一个和最后一个元素:
slice = slice[1:len(slice)-1]
[练习:写出上述赋值后的slice
变量的sliceHeader
结构的样子。]
你经常会听到有经验的Go程序员谈论“切片头sliceHeader
”,因为它实际上是存储在切片变量中的东西。例如,当你调用一个以切片为参数的函数时,例如bytes.IndexRune
,切片头就是传递给函数的内容。在以下调用中,
slashPos := bytes.IndexRune(slice, '/')
传递给IndexRune
函数的slice
参数实际上是一个“切片头”。
切片头中还有一个数据项,我们将在下面讨论,但首先让我们看看当使用切片编程时,切片头的存在意味着什么。
把切片传递给函数
重要的是要理解,即使切片包含指针,它本身也是一个值。在底层,它是一个结构体值,包含一个指针和一个长度,而不是指向某个结构体值的指针。
这很重要。
当我们在前面的例子中调用IndexRune
函数时,传递了一个切片头的副本。这种行为具有重要的影响。
考虑一下这个简单的函数:
func AddOneToEachElement(slice []byte) {
for i := range slice {
slice[i]++
}
}
顾名思义,该函数迭代切片的索引(使用for range循环),使其元素的值加1。试试看:
func main() {
slice := buffer[10:20]
for i := 0; i < len(slice); i++ {
slice[i] = byte(i)
}
fmt.Println("before", slice)
AddOneToEachElement(slice)
fmt.Println("after", slice)
}
(如果你想探索,可以编辑并执行上述可运行的代码段。)
即使切片头是按值传递的,它也包含指向数组元素的指针,因此原始切片头和传递给函数的切片头副本都描述了同一个底层数组。因此,当函数返回时,可以通过原始切片头看到被修改后的元素。
函数的切片实参确实是一个副本,如本例所示:
func SubtractOneFromLength(slice []byte) []byte {
slice = slice[0 : len(slice)-1]
return slice
}
func main() {
fmt.Println("Before: len(slice) =", len(slice))
newSlice := SubtractOneFromLength(slice)
fmt.Println("After: len(slice) =", len(slice))
fmt.Println("After: len(newSlice) =", len(newSlice))
}
在这里,我们看到切片参数的内容可以由函数修改,但其切片头不能。存储在切片变量中的长度不会被函数的调用所修改,因为函数传递的是切片头的副本,而不是原始切片头。因此,如果我们想编写一个修改切片头的函数,我们必须将其作为结果参数返回,就像我们在这里所做的那样。slice
变量不变,但返回的值具有新的长度,然后将其存储在newSlice
中,
切片指针:方法的接收者
让函数修改切片头的一种方法是将指针传递给它。下面是我们前面示例的一个变体:
func PtrSubtractOneFromLength(slicePtr *[]byte) {
slice := *slicePtr
*slicePtr = slice[0 : len(slice)-1]
}
func main() {
fmt.Println("Before: len(slice) =", len(slice))
PtrSubtractOneFromLength(&slice)
fmt.Println("After: len(slice) =", len(slice))
}
这个例子使用了指向切片的指针,但看起来很笨拙。要修改切片,我们通常使用指针接收者。
假设我们有一个方法,在最后一个斜杠处截断切片。我们可以这样写:
type path []byte
func (p *path) TruncateAtFinalSlash() {
i := bytes.LastIndex(*p, []byte("/"))
if i >= 0 {
*p = (*p)[0:i]
}
}
func main() {
pathName := path("/usr/bin/tso") // Conversion from string to path.
pathName.TruncateAtFinalSlash()
fmt.Printf("%s\n", pathName)
}
如果运行此示例,你将看到它能合理地工作,更改函数中的切片。
[练习:将接收者的类型更改为值而不是指针,然后再次运行。解释会发生什么。]
另一方面,如果我们想为path
编写一个方法,使path
中的ASCII字母大写(简单地忽略非英文字母),则该方法可以传入一个切片值,因为切片值接收者仍将指向相同的底层数组。
type path []byte
func (p path) ToUpper() {
for i, b := range p {
if 'a' <= b && b <= 'z' {
p[i] = b + 'A' - 'a'
}
}
}
func main() {
pathName := path("/usr/bin/tso")
pathName.ToUpper()
fmt.Printf("%s\n", pathName)
}
在这里,ToUpper
方法使用for range中的两个迭代变量来捕获slice
的下标和元素。
[练习:将ToUpper
方法转换为使用指针接收者,并查看其行为是否发生变化。]
[高级练习:改写ToUpper
方法以处理Unicode字母,而不仅仅是ASCII。]
容量
看看下面的函数,它将元素是int类型的切片扩展了一个元素:
func Extend(slice []int, element int) []int {
n := len(slice)
slice = slice[0 : n+1]
slice[n] = element
return slice
}
看看切片是如何生长的,直到不能生长为止。
现在是时候讨论切片头的第三个组成部分了:它的容量。除了数组指针和长度之外,切片头还存储其容量:
type sliceHeader struct {
Length int
Capacity int
ZerothElement *byte
}
Capacity
字段记录切片的底层数组实际拥有的空间;它是长度Length
可以达到的最大值。试图将切片增长到超出其容量的程度将超出底层数组空间的限制,并引发panic
。
上面的代码中,我们这么创建切片:
slice := iBuffer[0:0]
它的切片头类似于如下结构:
slice := sliceHeader{
Length: 0,
Capacity: 10,
ZerothElement: &iBuffer[0],
}
Capacity
字段等于切片的底层数组的长度,减去切片的第一个元素在数组中的索引(在上述情况下为零)。如果你想查询切片的容量,可以使用Go内置函数ca
p:
if cap(slice) == len(slice) {
fmt.Println("slice is full!")
}
make
如果我们想让切片超出其容量,该怎么办?你不做不到!根据定义,容量是切片增长的极限。但是,你可以创建一个容量更大的新数组,复制旧切片的数据到这个新数组,然后让旧切片指向这个新数组。
让我们开始创建。我们可以使用内置函数new
来分配一个更大的数组,然后对结果进行切片,但使用内置函数make
会更简单,它创建一个新数组并创建一个切片头来指向它。函数make
接受三个参数:切片的类型、初始长度和容量。容量即make
创建的用于保存切片数据的底层数组的长度。以下这个调用创建了一个长度为10的切片,还有5个的额外的空间可以扩展:
slice := make([]int, 10, 15)
fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))
以下这个代码片段使int
切片的容量增加了一倍,但长度保持不变:
slice := make([]int, 10, 15)
fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))
newSlice := make([]int, len(slice), 2*cap(slice))
for i := range slice {
newSlice[i] = slice[i]
}
slice = newSlice
fmt.Printf("len: %d, cap: %d\n", len(slice), cap(slice))
运行此代码后,在需要再次重新分配空间之前,切片已经有更多的增长空间。
在创建切片时,长度和容量通常是相同的。内置函数make
对这种常见情况有一个简写。length
参数默认等于容量,因此可以省略容量参数,将两者设置为相同的值:
gophers := make([]Gopher, 10)
切片gophers
的长度和容量都设置为10。
Copy
当我们在上一节中将切片的容量加倍时,我们编写了一个循环来将旧数据复制到新切片。Go有一个内置函数copy
,可以让这变得更容易。它的参数是两个切片,并将数据从右侧参数复制到左侧参数。以下是我们使用copy
的示例:
newSlice := make([]int, len(slice), 2*cap(slice))
copy(newSlice, slice)
copy
很智能,它只复制它可以复制的内容,并注意两个切片参数的长度。 换句话说,它复制的元素数量是两个切片长度中的小的那个。此外,copy
返回一个整数值,即它复制的元素数量,尽管并不总是值得检查这个返回值。
当源切片和目标切片重叠时,copy
函数也能正确处理,这意味着它可以用于在单个切片中移动元素。以下示例如何使用copy
将值插入切片的中间:
// Insert函数在切片slice指定的下标index处插入元素值value,index不能超出切片slice的下标范围,并且切片slice必须还有额外容量可供插入新元素
func Insert(slice []int, index, value int) []int {
// 先给切片slice扩展一个元素的空间
slice = slice[0 : len(slice)+1]
// 使用copy函数把切片slice的从index下标开始的右半部分元素,往右移动一格位置,以在index处空出一个位置
copy(slice[index+1:], slice[index:])
// 把值value存入index处
slice[index] = value
return slice
}
在这个函数中有几点需要注意。首先,当然,它必须返回更新后的切片,因为它的长度已经改变。其次,它使用了一种方便的简写。表达式:
slice[i:]
与以下表达式等价:
slice[i:len(slice)]
此外,尽管我们还没有使用这个技巧,但我们也可以省略切片表达式的第一个元素;它默认为零。因此:
slice[:]
指切片本身,这在对数组进行切片时很有用。以下这个表达式是创建一个“描述数组所有元素的切片”的最短的表达式:
array[:]
现在,让我们运行Insert
函数:
slice := make([]int, 10, 20) // 注意创建的切片的容量要大于长度,才能插入元素
for i := range slice {
slice[i] = i
}
fmt.Println(slice)
slice = Insert(slice, 5, 99)
fmt.Println(slice)
Append函数示例
在前几节中,我们编写了一个Extend
函数,该函数将切片扩展一个元素。不过,它有缺陷,因为如果切片的容量太小,该函数就会崩溃。(我们的Insert函数也有同样的问题。)现在我们已经准备好了解决这个问题的代码,所以让我们为整数切片编写一个健壮的Extend
实现吧:
func Extend(slice []int, element int) []int {
n := len(slice)
if n == cap(slice) {
// 切片容量满了,必须扩容。在这里把切片的容量变成原来的2倍
newSlice := make([]int, len(slice), 2*len(slice)+1)
copy(newSlice, slice)
slice = newSlice
}
slice = slice[0 : n+1]
slice[n] = element
return slice
}
在这个函数里,尤为重要的是最后要返回切片,因为当源切片被重新分配容量时,得到的切片描述的是一个完全不同的底层数组。以下是一个小片段来演示填充切片时会发生什么:
slice := make([]int, 0, 5)
for i := 0; i < 10; i++ {
slice = Extend(slice, i)
fmt.Printf("len=%d cap=%d slice=%v\n", len(slice), cap(slice), slice)
fmt.Println("address of 0th element:", &slice[0])
}
当大小为5的初始切片被填满时,会重新分配一个底层数组。分配新数组时,第零个元素的地址和数组容量都会发生变化。
有了强大的Extend
函数作为指导,我们可以编写一个更好的函数,通过多个元素来扩展切片。为此,我们使用Go的语法,在调用函数时将函数参数列表转换为切片。也就是说,我们使用Go的参数列表长度可变的函数。
让我们调用Append
函数。对于第一个版本,我们可以重复调用Extend
函数,这样参数列表变长的函数的机制就很清楚了。Append
函数的签名如下:
func Append(slice []int, items ...int) []int
这意味着Append
函数接受一个参数,即一个切片,然后是零个或多个int
参数。就Append
的实现而言,这些参数正是int
切片的一部分:
// Append函数把items添加到切片slice。
// 第一个版本:仅仅循环调用Extend函数。
func Append(slice []int, items ...int) []int {
for _, item := range items {
slice = Extend(slice, item)
}
return slice
}
请注意,for range
循环在items
参数的元素上迭代,该参数具有隐含的类型[]int
。还要注意使用空白标识符_
来丢弃循环中的索引,在这种情况下我们不需要它。
试试看:
slice := []int{0, 1, 2, 3, 4}
fmt.Println(slice)
slice = Append(slice, 5, 6, 7, 8)
fmt.Println(slice)
本例中的另一项新技术是,我们通过编写一个字面量来初始化切片slice
,由切片的类型及其大括号中的元素组成:
slice := []int{0, 1, 2, 3, 4}
Append
函数之所以有趣,还有一个原因。我们不仅可以附加元素,还可以通过使用…
符号来展开一个切片的所有元素:
slice1 := []int{0, 1, 2, 3, 4}
slice2 := []int{55, 66, 77}
fmt.Println(slice1)
slice1 = Append(slice1, slice2...) // ...符号
fmt.Println(slice1)
当然,我们可以在Extend
函数的内部,通过不超过一次的分配来提高Append
函数的效率:
// 更加高效的Append版本。
func Append(slice []int, elements ...int) []int {
n := len(slice)
total := len(slice) + len(elements)
if total > cap(slice) {
// 重新分配容量为原来的1.5倍
newSize := total*3/2 + 1
newSlice := make([]int, total, newSize)
copy(newSlice, slice)
slice = newSlice
}
slice = slice[:total]
copy(slice[n:], elements)
return slice
}
在这里,请注意我们如何使用copy
函数两次,一次是将切片数据移动到新分配的内存,另一次是将要添加的元素复制到切片slice
旧数据的末尾。
试试看;行为与以前相同:
slice1 := []int{0, 1, 2, 3, 4}
slice2 := []int{55, 66, 77}
fmt.Println(slice1)
slice1 = Append(slice1, slice2...)
fmt.Println(slice1)
Append内置函数
因此,我们得出了设计append
内置函数的动机。它与我们的Append
示例完全一样,具有同等的效率,但它适用于任何切片类型。
Go的一个弱点是任何泛型类型的操作都必须由运行时提供。总有一天,这种情况可能会改变,但目前,为了更容易地处理切片,Go提供了一个内置的通用的append
函数。它的工作原理与我们的int
切片版本相同,但适用于任何切片类型。
请记住,由于切片头总是会被append
函数更新,因此需要在调用后保存返回的切片头。事实上,编译器不允许在不保存结果的情况下调用append
函数。
// 创建两个切片
slice := []int{1, 2, 3}
slice2 := []int{55, 66, 77}
fmt.Println("Start slice: ", slice)
fmt.Println("Start slice2:", slice2)
// 添加元素到切片
slice = append(slice, 4)
fmt.Println("Add one item:", slice)
// 添加一个切片里的所有元素到另一个切片
slice = append(slice, slice2...)
fmt.Println("Add one slice:", slice)
// 复制一个切片,然后赋值给另一个切片
slice3 := append([]int(nil), slice...)
fmt.Println("Copy a slice:", slice3)
// 复制一个切片里的所有元素,然后追加到这个切片的尾部
fmt.Println("Before append to self:", slice)
slice = append(slice, slice...)
fmt.Println("After append to self:", slice)
值得花点时间详细思考以上代码的最后三行。
在社区构建的“Slice Tricks”Wiki页面上,还有更多关于函数append
、copy
和其他使用切片的方法的示例。
Nil
根据我们新学到的知识,我们可以知道nil
切片是什么。自然,它是切片头的零值(zero value):
sliceHeader{
Length: 0,
Capacity: 0,
ZerothElement: nil,
}
或者仅是:
sliceHeader{}
关键细节是其指向底层数组元素的指针也是nil
。由一下代码创建的切片:
array[0:0]
长度是0,也许容量也是0,但是其指向底层数组元素的指针不是nil
,因此它不是nil
切片。
应该清楚的是,空(empty)切片可以增长(假设它具有非零容量),但nil
切片没有可放入值的数组,并且永远不能增长到容纳哪怕一个元素。
也就是说,nil
切片在功能上等同于零长度的切片,即使它什么都不指向。它的长度为零,但可以被append
函数使用。举个例子,看看上面的那一行代码,通过附加到一个nil
切片来复制一个切片。
String
现在简要介绍一下Go中的与切片相关的字符串。
字符串实际上非常简单:它们只是只读的字节片,再加上Go语言提供了一些额外的语法支持。
因为它们是只读的,所以不需要容量(不能增长它们),但在其他方面,对于大多数目的,你可以将它们视为只读的字节片。 对于初学者,我们可以对它们进行索引以访问单个字节:
slash := "/usr/ken"[0] // 返回'/'
我们可以通过切片一个字符串来获取它的子串:
usr := "/usr/ken"[0:4] // 返回字符串"/usr"
现在,当我们切片一个字符串时,幕后发生的事情应该很明显了。
我们还可以从一个普通的字节切片,通过简单的强制类型转换从中创建一个字符串:
str := string(slice)
反过来也一样:
slice := []byte(usr)
字符串下面的数组在视图中是隐藏的;除了通过字符串之外,无法访问其内容。这意味着,当我们进行这两种转换时,必须制作数组的一个副本。Go当然会处理好这一点,所以你不必自己这么做。在这两种转换之后,对字节片底层的数组的修改就不会影响相应的字符串。
这种类似切片的字符串设计的一个重要结果是,创建子字符串非常高效。所需要做的就是创建一个字符串头。由于字符串是只读的,原始字符串和切片操作产生的字符串可以安全地共享同一个底层数组。
在Go的早期版本,字符串的最早实现是总会被分配一个底层数组,但当切片被添加到Go中时,它们提供了一个高效的字符串处理的模型。因此,在一些性能测试里表现出了巨大的加速。
当然,字符串还有很多内容可讲,另外一篇博客文章《Go中的字符串,字节,rune和字符(character)》对它进行了更深入的介绍。
结论
要了解切片是如何工作的,了解它们是如何实现的会有所帮助。有一个小数据结构,即切片头。当我们四处传递切片值时,切片头会被复制,但它指向的底层数组总是共享的。
一旦你了解了它们的工作原理,切片不仅易于使用,而且功能强大且富有表现力,尤其是在copy
和append
内置函数的帮助下。
更多切片相关文章
关于Go中的切片,还有很多值得学习的文章。如前所述,Wiki页面“Slice Tricks”有许多示例。Go Slices博客文章用清晰的图表描述了内存布局的细节。Russ Cox的《Go切片(slice):用法和内部结构》文章包括了对切片的讨论以及Go的一些其他内部数据结构。
还有更多的资料,但了解切片的最好方法是使用它们。
2023/08
JOEL SPOLSKY
有没有想过那个神秘的Content-Type
标签?你知道应该把它放在HTML中,但你永远不知道它应该是什么?
你有没有收到过保加利亚朋友发来的电子邮件,主题是“???? ?????? ??? ????”?
我沮丧地发现,在字符集(character set)、编码(encoding)、Unicode等神秘的世界里,有多少软件开发人员并没有真正跟上进度。几年前,一位FogBUGZ的测试人员想知道它是否能处理日语的电子邮件。日语?他们有日语的电子邮件?我不知道。当我仔细观察我们用来解析MIME电子邮件的商业的ActiveX控件时,我们发现它在字符集上的做法是完全错误的,所以我们实际上不得不编写很难的代码来撤销它所做的错误转换,并重做正确的做法。当我查看另一个商业库时,它也有一个完全错误的字符相关的代码实现。我和那个软件包的开发人员通信,他认为他们“对此无能为力”。和许多程序员一样,他只是希望一切都能以某种方式结束。
然而并不会。当我发现流行的web开发编程语言PHP几乎完全不知道字符编码问题,愉快地使用8位字符,几乎不可能开发出良好的国际化web应用程序时,我想,适可而止吧。
因此,我要宣布:如果你是一名在2003年工作的程序员,你不知道字符、字符集、编码和Unicode的基本知识,而我抓住了你,我会惩罚你,让你在潜艇里剥6个月的洋葱。我发誓我会的。
其实也没有那么难。
在这篇文章中,我将向你详细介绍每个在职程序员应该知道的内容。所有关于“纯文本=ascii=字符为8位(bit)”的东西不仅是错误的,而且是无可救药的错误,如果你仍然以这种方式编程,你不会比一个不相信细菌的医生好多少。在读完这篇文章之前,请不要再写一行代码。
在我开始之前,我应该警告你,如果你是少数了解国际化的人之一,你会发现我的整个讨论有点过于简单化。我真的只是想在这里设置一个最低标准,这样每个人都能理解正在发生的事情,并编写代码,能处理除英语子集(其中不包括带口音的单词)之外的任何语言的文本。我应该警告你,字符处理只是创建国际通用软件所需的一小部分,但我一次只能写一件事,所以今天只写字符集相关的部分。
历史的视角
理解这些东西最简单的方法是按时间顺序排列。
你可能认为我会在这里谈论非常古老的字符集,比如EBCDIC。我不会的。EBCDIC与你的生活无关。我们不必回到那么远的过去。
回到中古时代,当Unix被发明,K&R正在编写C编程语言时,一切都很简单。EBCDIC正在退出。唯一重要的字符是老式无重音的英文字母,我们有一个名为ASCII的代码,它可以用32到127之间的数字表示每个字符。空格是32,字母“A”是65,等等。这可以方便地存储在7位二进制数中。当时的大多数计算机都使用8位的字节(译者注:1字节(Byte)由8位(bit)组成),所以你不仅可以存储每一个可能的ASCII字符,而且你还有一个多余的位(bit)可供使用,如果你很邪恶,你可以将其用于自己的邪恶目的。低于32的代码被用于编码不可打印的字符,并被用来咒骂。开个玩笑。它们被用于“控制字符”,比如7会让你的电脑发出嘟嘟声,12会让当前的一页纸飞出打印机,并输入新的一页。
假设你是一个英语世界的人,那么一切都很好。
因为1个字节最多可以容纳8位,所以很多人开始想,“天哪,我们可以把128到255这些代码用于自己的目的。”问题是,很多人同时也有这个想法,他们对128到255之间的空间也有自己的想法。IBM-PC有一种后来被称为OEM字符集的东西,它为欧洲语言提供了一些重音字符和一堆白描字符(line drawing characters)……水平条、垂直条、右侧悬挂着小叮当的水平条等等,你可以用这些白描字符在屏幕上制作漂亮的框和线,你仍然可以在干洗店的8088电脑上看到运行着它的程序。
事实上,当人们开始在美国以外的地方购买电脑时,人们就想出了各种不同的OEM字符集,它们都将前128个字符用于自己的目的。例如,在一些电脑上,字符代码130会显示为é,但在以色列销售的电脑上,它是希伯来语字母Gimel(),所以当美国人将他们的简历résumés发送到以色列时,他们会以rsums的形式到达。在许多情况下,比如俄罗斯人,对于如何处理128个以上的字符有很多不同的想法,所以你甚至无法可靠地与他们交换俄语文档。
最终,这个免费的OEM被编入了ANSI标准。在ANSI标准中,每个人都同意在128以下字符是什么,这与ASCII几乎相同,但根据你居住的地方,有很多不同的方法来处理128及以上的字符。这些不同的系统被称为代码页(code page)。例如,在以色列,DOS使用了一个名为862的代码页,而希腊用户使用的是737。128以下的字母相同,但128以上的字母不同,所有有趣的字母都在128以上。国际版本的MS-DOS操作系统有几十个这样的代码页,处理从英语到冰岛语的所有内容,他们甚至有几个“多语言”代码页,可以在同一台计算机上处理世界语和加利西亚语!哇!但是,除非你自己编写自定义程序,使用位图图形显示所有内容,否则在同一台计算机上获得希伯来语和希腊语是完全不可能的,因为希伯来语和希腊文需要不同的代码页,有不同的解释。
与此同时,在亚洲,更疯狂的事情正在发生,因为亚洲字母表有数千个字母,而这些字母用8位无法全部表示。这通常可以通过称为DBCS的混乱系统来解决,DBCS是一种“双字节字符集”,其中一些字母存储在一个字节中,另一些则存储在两个字节中。向前遍历一个字符串很容易,但向后遍历几乎是不可能的。鼓励程序员不要使用s++和s–来前后移动,而是调用Windows的AnsiNext和AnsiRev等知道如何处理整个混乱的函数。
但是,大多数人只是假装一个字节是一个字符,一个字符是8位,只要你从不把字符串从一台计算机移动到另一台计算机,或者你只说一种语言,这一假设就会一直起作用。但互联网一出现,把字符串从一台电脑转移到另一台电脑就变得很常见了,整个混乱局面随之而来。幸运的是,Unicode被发明了。
Unicode
Unicode是一项勇敢的努力,创建了一个包含地球上所有书写系统里合理的单个字符集,还包括一些像克林贡语这样的虚构的书写系统。有些人误解Unicode只是一个16位的字符集,每个字符占用16位,因此可能有65536个字符。事实上,这是不对的。这是关于Unicode的一个最常见的误解,所以如果你这么想的话,不要感到难过。
事实上,Unicode对字符有不同的思考方式,你必须理解Unicode对事物的思维方式,否则什么都没有意义。
到目前为止,我们一直假设一个字母映射到一些可以存储在磁盘或内存中的位:
A -> 0100 0001
在Unicode中,字母映射到一个称为代码点(code point,简称“码点”)的东西,这仍然只是一个理论上的概念。码点是如何在内存或磁盘中表示的,这是一个非常疯狂的故事。
在Unicode中,字母A是柏拉图式的理想化的东西。它只是漂浮在天堂:
A
这个柏拉图式的A不同于B,也不同于a,但与A、A(斜体)和A(粗体)相同。Times New Roman字体中的A与Helvetica字体中的A是相同的字符,但与小写的“a”不同,这一观点似乎没有太大争议,但在某些语言中,仅仅弄清楚字母是什么可能会引起争议。德语字母ß是一个真正的字母还是一种奇特的ss的书写方式?如果一个字母的形状在单词末尾发生变化,那是另一个字母吗?希伯来语(Hebrew)说是,阿拉伯语(Arabic)说不是。总之,Unicode委员会的聪明人在过去十年左右的时间里一直在想这个问题,伴随着大量高度政治化的辩论,你不必担心。他们已经想清楚了。
每个字母表中的每个柏拉图式的字母都被Unicode委员会分配了一个神奇的数字,例如:U+0639。这个神奇的数字被称为码点(code point)。U+的意思是“Unicode”,数字是十六进制的。U+0639是阿拉伯字母Ain的码点。英文字母A的码点是U+0041。你可以使用Windows 2000/XP上的charmap实用程序或访问Unicode网站来查找各种字母的码点或者通过码点来查找对应的字母。
Unicode可以定义的字母数量没有真正的限制,事实上,它们已经超过了65536个了,所以并不是每个Unicode字母都可以被压缩成两个字节,但无论如何,这都是一个神话。
好吧,假设我们有一个字符串:
Hello
在Unicode中对应以下5个码点:
U+0048 U+0065 U+006C U+006C U+006F
只是一堆码点。包括数字。我们还没有谈到如何将其存储在内存中或在电子邮件中如何表示。
编码(Encoding)
那就到了编码(encoding)出场的时候了。
Unicode编码最早的想法是,让我们把这些码点数字分别存储在两个字节中,这导致了关于两个字节的神话。所以Hello被存储为:
00 48 00 65 00 6C 00 6C 00 6F
对吗?不要那么快下结论!难道它也不能是:
48 00 65 00 6C 00 6C 00 6F 00
吗?好吧,从技术上讲,是的,我确实相信它可以,事实上,早期的实现者希望能够以大端(high-endian)或小端(low-endian)模式存储他们的Unicode码点,无论他们的CPU是快还是慢,瞧,现在已经有两种存储Unicode的方法了。因此,人们不得不想出一个奇怪的约定,在每个Unicode字符串的开头存储一个FE FF;这被称为Unicode字节顺序标记(Unicode Byte Order Mark),如果你交换高字节和低字节,它看起来就像一个FF FE,阅读你的字符串的人会知道他们必须每隔一个字节交换一次。唉,但是并非每一个Unicode字符串的开头都有一个字节顺序标记(译者注:有些程序员可能不知道或者不遵守“Unicode字节顺序标记”约定)。
有一段时间,这似乎已经足够好了,但程序员们一直在抱怨。“看看那些零!”他们说,因为他们是美国人,他们看的是英语文本,很少使用U+00FF以上的码点。此外,他们是加利福尼亚州的自由派嬉皮士,他们喜欢节约。如果他们是得克萨斯人,他们不会介意消耗两倍的字节数。但是,那些加州的懦夫无法忍受将字符串的存储量增加一倍的想法,而且无论如何,已经存在很多使用各种ANSI和DBCS字符集的该死的文档了,谁来转换它们呢?仅仅因为这个原因,大多数人就忽略Unicode很多年,与此同时,情况变得更糟了。
因此UTF-8这一绝妙的概念诞生了。UTF-8是另一种使用8位字节在内存中存储Unicode码点字符串(即神奇的U+数字)的系统。在UTF-8中,0到127的每个码点都存储在一个字节中。只有128及以上的码点使用2个字节,3个字节,实际上,最多使用6个字节来存储。
这有一个巧然,即英语文本在UTF-8中和在ASCII中看起来完全一样,所以美国人甚至不会注意到任何错误。只有世界上的其他地方才需要跳过重重关卡。具体来说,Hello字符串的Unicode码点是U+0048U+0065U+006CU+0006CU+006F,将被存储为UTF-8编码48656C6C6F,瞧!与存储在ASCII、ANSI和OEM字符集中的相同。现在,如果你大胆地使用重音字母、希腊字母或克林贡语(Klingon)字母,你将不得不使用几个字节来存储一个Unicode码点,但美国人永远不会注意到这些。(UTF-8还有一个很好的特性,即想要使用单个字节0作为null终止符的处理字符串的旧代码,任可正确运行)。
到目前为止,我已经告诉你编码Unicode码点的三种方法。传统的双字节存储方法被称为UCS-2(因为它使用两个字节)或UTF-16(因为使用16个bit),但你仍然需要弄清楚它是大端UCS-2还是小端UCS-2。还有一种流行的UTF-8标准,它有一个很好的特性,即英语文本和完全不知道除了ASCII字符之外还有其他东西的老程序,无需改动就可以正常工作。
实际上还有很多其他编码Unicode的方法。有一种叫做UTF-7编码的东西,它很像UTF-8,但保证高位总是零,所以如果你必须通过某种认为7位就足够了的严厉的警察国家的电子邮件系统来传递Unicode,谢谢你,它确实可以毫发无损地传递。还有有UCS-4编码,它将每个码点存储在4个字节中,它有一个很好的特性,即每个Unicode码点都可以存储在相同数量的字节中,但天哪,即使是德克萨斯人也不会这样大胆地浪费那么多内存。
现在你正在考虑的是可以用Unicode码点表示的柏拉图式的理想的字母,事实上,这些Unicode码点也可以用任何老式的编码方案编码!例如,你可以用ASCII编码Unicode字符串Hello(U+0048 U+0065 U+006C U+0006C U+006F),或者旧的OEM希腊编码,或者希伯来语ANSI编码,或者迄今为止发明的数百种编码中的任何一种,但有一个问题:有些字母可能无法显示出来!如果你试图在编码中表示的Unicode码点没有对应的可显示的字符,你通常会得到一个小问号:?,或者得到一个装在盒子里的小问号:�。
有数百种传统编码只能正确存储某些码点,并将所有其他码点显示为问号。一些流行的英语文本编码是Windows-1252(西欧语言的Windows 9x标准)和ISO-8859-1,也就是Latin-1(也适用于任何西欧语言)。但试着把俄语或希伯来语字母存储在这些编码中,你会得到一堆问号。UTF 7、8、16和32都具有能够正确存储任何码点的良好特性。
关于编码的一个最重要的事实
如果你完全忘记了我刚才解释的一切,请记住一个极其重要的事实——在不知道使用什么编码的情况下使用字符串是没有意义的。你不能再把头埋在沙子里,假装“纯”文本都使用ASCII编码。
没有“纯”文本这样的东西!
如果你在内存、文件或电子邮件中有一个字符串,你必须知道它的编码,否则你无法正确解析或向用户显示它。
几乎每一个愚蠢的“我的网站看起来像胡言乱语”或“当我使用重音符号时,她看不懂我的电子邮件”的问题都源于一个天真的程序员,他不明白一个简单的事实,即如果你不告诉我一个特定的字符串是使用UTF-8还是ASCII、ISO 8859-1(Latin 1)还是Windows 1252(西欧)编码的,你根本无法正确显示它,甚至无法弄清楚它的结束位置。码点127以上有一百多种编码,所有的赌注都是徒劳的。
我们如何保存关于使用的字符串编码的信息?好吧,有标准的方法可以做到这一点。对于电子邮件,你应该在表单的标头中有一个字符串:
Content-Type: text/plain; charset="UTF-8"
对于网页,最初的想法是web服务器将返回一个包含类似于Content-Type字段的HTTP标头以及网页本身——Content-Type字段不是放在HTML内容本身中,而是在HTML页面之前发送的响应头里的字段之一。
这会导致一些问题。假设你有一个大型的网络服务器,里面有很多网站和数百个页面,这些网站和页面由很多人以各种不同的语言提供,并且都使用他们认为适合生成的Microsoft FrontPage副本的任何编码。web服务器本身并不真正知道每个文件是用什么编码编写的,因此无法发送Content-Type标头。
如果你可以使用某种特殊的标记将HTML文件的Content-Type直接放在HTML文件本身中,那将是非常方便的。当然,这会让纯粹主义者疯狂……你怎么能在知道HTML文件的编码之前阅读它呢?!幸运的是,几乎所有常用的编码都对32到127之间的字符做同样的事情,所以你总是可以在HTML页面里做到这一点:
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
但这个元标记确实必须是<head>节的第一个标记,因为一旦web浏览器看到这个标记,它就会停止解析页面,并使用你指定的编码重新开始解释整个页面。
如果web浏览器在http响应头或元标记中找不到任何Content-Type,该怎么办?Internet Explorer实际上做了一件很有趣的事情:它试图根据各种语言的典型编码中各种字节在典型文本中出现的频率来猜测使用了什么语言和编码。因为各种旧的8位代码页倾向于将其国家字母放在128到255之间的不同范围内,而且因为每种人类语言在字母使用上都有不同的特征直方图,所以这种方法实际上是可行的。这真的很奇怪,但它似乎经常起作用,初级的网页作者从不知道自己需要一个“Content-Type”字段,他们在网络浏览器中查看自己的页面,看起来还可以,直到有一天,他们写的东西与他们母语的字母频率分布不完全一致,而Internet Explorer认为它是韩语并如此显示,我认为,坦率地说,波斯特尔定律(Postel’s Law)关于“保守输出,自由输入”的观点并不是一个好的工程原则。无论如何,这个网站是用保加利亚语写的,但似乎是韩语(甚至不一定是韩语),可怜的读者会怎么做?他使用“视图|编码”菜单,尝试一系列不同的编码(东欧语言至少有十几种),直到画面变得更清晰。但是大多数网站开发者都不会这么做。
对于我公司发布的网站管理软件CityDesk的最新版本,我们决定在内部使用UCS-2(两字节)Unicode,这是Visual Basic、COM和Windows NT/2000/XP使用的本地字符串类型。在C++代码中,我们只是将字符串声明为wchar_t(“wide char”)而不是char,并使用wcs系列函数而不是str系列函数(例如wcscat和wcslen而不是strcat和strlen)。要在C代码中创建一个文本UCS-2字符串,只需在其前面放一个L,例如L”Hello”。
当CityDesk发布网页时,它会将其转换为UTF-8编码,浏览器多年来一直很支持UTF-8编码。这就是Joel on Software所有29种语言版本的编码方式,我还没有听说有人在查看它们时遇到任何问题。
这篇文章越来越长了,我不可能涵盖关于字符编码和Unicode的所有知识,但我希望如果你读过这篇文章,你就知道了足够的知识,可以回到编程上来,使用抗生素而不是水蛭和咒语来治病,这是我现在留给你的任务。
前置条件
在Centos7上安装PHP8之前,必须安装EPEL(企业Linux的额外软件包)存储库。你可以通过运行以下命令进行安装:
sudo yum install epel-release
在Centos7上安装PHP8
1.将Remi存储库添加到你的CentOS7的系统中。此存储库为各种Linux发行版提供了PHP的更新版本:
sudo yum install https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
sudo yum install http://rpms.remirepo.net/enterprise/remi-release-7.rpm
2.通过运行以下命令禁用Remi存储库中的旧PHP版本的安装包:
sudo yum install yum-utils
sudo yum-config-manager --disable remi-php*
sudo yum-config-manager --disable php-5*
否则你运行sudo yum install php
命令安装PHP时,可能找到的是PHP 5.x版本的安装包。
3.通过运行以下命令启用Remi存储库中的PHP 8.x版本的安装包:
sudo yum-config-manager --enable remi-php82
截至2023/09/22,Remi存储库中还没有php8.3版本的安装包,于是我们启用php8.2的安装包。
4.通过运行以下命令更新系统里的程序包列表:
sudo yum update
5.通过运行以下命令安装PHP:
sudo yum install php
如果yum说php8.2依赖httpd,但是系统里没有安装httpd。CentOS7系统的默认仓库/etc/yum.repos.d/CentOS-Base.repo里已经包含了httpd软件的安装包,执行以下命令安装:
sudo yum install httpd
成功安装httpd后,再次运行以下命令安装PHP:
sudo yum install php
可以看到yum找到的是PHP8.2版本的安装包:
键入y再按回车键,开始下载安装……
6.通过运行以下命令查看PHP是否安装成功:
php -v
如果输出如下信息,就表示我们成功安装PHP8.2了:
PHP 8.2.10 (cli) (built: Aug 29 2023 15:31:38) (NTS gcc x86_64)
Copyright (c) The PHP Group
Zend Engine v4.2.10, Copyright (c) Zend Technologies
7.通过运行以下命令安装常用PHP扩展库:
sudo yum install php-fpm php-mysqlnd
在yum解析依赖过程中的输出信息中,我们需要注意一下这些即将被安装的PHP扩展库的版本应该也是8.2,并且应该也是从remi-php82仓库里下载的。
可以通过运行以下命令查看目前安装了哪些PHP扩展库:
php -m
参考
https://baransel.dev/post/how-to-install-php8-on-centos/
https://www.tecmint.com/install-php-8-on-centos/
这一提示产生原因是什么?当系统安装了RHEL(RedHat)软件仓库时,可能会产生此提示。
如何禁止这一提示?有以下两种方法。
方法一,使用你喜欢的文本编辑器(nano、vi或vim)打开subscription-manager.conf配置文件:
sudo vim /etc/yum/pluginconf.d/subscription-manager.conf
设置enabled配置项的值为0:
enabled=0
保存文件并退出文本编辑器。
方法二,使用Red Hat Subscription Manager工具将RHEL系统注册并订阅到Red Hat客户的门户网站,怎么注册参考https://access.redhat.com/solutions/253273
参考
本文翻译自《Strings, bytes, runes and characters in Go》。
Rob Pike
2013/08/23
介绍
上一篇博客文章解释了切片(slice)在Go中的工作方式,并使用了一些示例来说明它背后的机制。在此背景下,本文将讨论Go中的字符串。起初,字符串对于一篇博客文章来说可能太简单了,但想要很好地使用它们,不仅需要了解它们是如何工作的,还需要了解字节、字符(character)和符文(rune)之间的区别,Unicode和UTF-8之间的区别、字符串和字符串字面量(string literal)之间的区别以及其他更微妙的区别。
编写这个话题的一种方法是,给出常见问题的答案,例如“当我在位置n索引Go字符串时,为什么我得不到第n个字符?”,正如你所看到的,这个问题的答案可以让我们了解文本在现代世界中是如何工作的。
Joel Spolsky的著名博客文章“每个软件开发人员绝对、积极地必须了解的Unicode和字符集”,是对其中一些问题的一个极好的介绍,它独立于Go语言。它提出的许多观点将在这里重复提及。
什么是字符串?
让我们从一些基础知识开始。
在Go中,一个字符串实际上是一个只读字节片。如果你不确定字节片是什么或者它是如何工作的,请阅读上一篇博客文章;我们在这里假设你已阅读。
重要的是要提前声明:一个字符串可以包含任意的字节,不一定是Unicode文本、UTF-8文本或任何其他预定义的格式。就字符串的内容而言,它完全等同于一个字节片。
下面是一个字符串字面量(稍后将详细介绍),它使用\xNN
表示法来定义一个包含一些特殊字节值的字符串常量。(当然,字节的范围从十六进制值00到FF,包括00和FF。)
const sample = "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98"
打印输出字符串
因为我们的示例字符串中的一些字节不是有效的ASCII,甚至不是有效的UTF-8,所以直接打印字符串会产生难看的输出。以下是简单的打印输出这个字符串的语句:
fmt.Println(sample)
产生这种混乱的输出(确切的输出与你的系统环境有关,不同的系统环境可能有不同的输出):
��=� ⌘
为了弄清楚这个字符串里到底装着什么,我们需要把它拆开,检查一下每个部分。有几种方法可以做到这一点。最明显的是对其内容进行循环,并单独取出字节,如以下for
循环中所示:
for i := 0; i < len(sample); i++ {
fmt.Printf("%x ", sample[i])
}
正如上面所示,对字符串进行索引访问到的是单个字节,而不是一个个字符(character)。我们将在下面详细讨论这个主题。现在,让我们只使用字节。这是逐字节遍历循环的输出:
bd b2 3d bc 20 e2 8c 98
注意各个字节如何与定义字符串的十六进制转义符相匹配。 把混乱的字符串输出为人类可读的形式的较简单的方法是,使用fmt.Printf
的%x
(十六进制数)格式。它将字符串的顺序字节输出为十六进制数字,每个字节对应两个十六进制数字。
fmt.Printf("%x\n", sample)
输出如下:
bdb23dbc20e28c98
你可以与之前的输出比较一下。
一个很好的技巧是在该格式中使用“空格”标志,在%
和x
之间加一个空格:
fmt.Printf("% x\n", sample)
输出如下:
bd b2 3d bc 20 e2 8c 98
注意字节之间的空格。
还有更多。%q
(带引号)格式将转义字符串中任何不可打印的字节序列,因此输出是明确的。
当字符串的大部分内容可以理解为文本,但也有一些特殊字符需要清除时,这种技巧很方便;对于上文中的字符串,它输出:
"\xbd\xb2=\xbc ⌘"
如果我们注视一下,我们可以看到隐藏在噪音中的是一个ASCII等号和一个普通空格,最后出现了著名的瑞典“兴趣地点(Place of Interest)”符号。该符号的Unicode码值为U+2318,被编码为UTF-8字节:e2 8c 98,位于空格(十六进制值20)之后。
如果我们对字符串中的奇怪字符感到陌生或困惑,我们可以在%q
格式中使用“加号+
”标志。此标志不仅转义不可打印的字节序列,而且转义任何非ASCII字节,都按UTF-8编码来解析。结果是,它打印输出了格式正确的UTF-8编码的Unicode码值,该值表示字符串中的非ASCII数据:
fmt.Printf("%+q\n", sample)
使用该格式,上述瑞典语符号的Unicode值显示为\u
开头的转义符:
"\xbd\xb2=\xbc \u2318"
这些打印输出技巧在调试字符串内容时很有用,在后续的讨论中也很方便。同样值得指出的是,所有这些方法对字节片的行为与对字符串的行为完全相同。
以下是我们在上文列出过的打印输出的选项(标志),作为一个完整的程序示例给出:
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)
}
[练习:修改上面的例子,输出字节切片而不是字符串。提示:使用转换来创建切片。]
[练习:在每个字节上使用%q
格式对字符串进行循环。输出会告诉你什么?]
UTF-8和字符串字面量
正如我们所看到的,对字符串进行索引会返回字节,而不是字符(character):字符串只是一堆字节。这意味着,当我们在字符串中存储一个字符值时,我们是按字节存储它的。让我们看一个更可控的例子,看看这是如何发生的。
这里有一个简单的程序,它用三种不同的方式打印带有单个字符的字符串常量,一种是打印输出纯字符串,一种是只打印输出ASCII字符,还有一种是打印输出十六进制数的单个字节。为了避免混淆,我们创建了一个“原始字符串(raw string)”,用后引号(back quotes)括起来,这样它就只能包含字符串字面量。(用双引号括起来的常规字符串里面可以包含转义字符,如上文所示。但用后引号括起来的原始字符串里面的字符不会被转义。)
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
表示,并且这些字节是十六进制数2318
的UTF-8编码。
根据你对UTF-8的熟悉程度,它可能很明显,也可能很微妙,但值得花点时间解释一下字符串的UTF-8表示是如何创建的。简单的事实是:它是在编写源代码时创建的。
Go语言的源代码被定义为UTF-8文本;不允许使用其他编码。这意味着,当我们在源代码中编写以下文本时
`⌘`
用于编写源代码的文本编辑器将符号⌘的UTF-8编码放入源代码文本中。当我们打印输出十六进制数的字节时,我们只是简单地输出文本编辑器放置在文件中的字节数据。
简而言之,Go的源代码是UTF-8文本,因此其字符串字面量也是UTF-8文本。如果该字符串字面量里不包含转义序列(原始字符串就不包含),则构造的字符串就是引号之间的源代码文本。因此,通过定义和构造,原始字符串将始终包含其内容里的有效UTF-8文本。类似地,除非像本文开头示例中的字符串(用\xNN
表示法来定义一个包含一些特殊字节值的字符串字面量)那样包含不能被解析为UTF-8编码的字节序列,否则普通字符串字面量也将始终包含有效的UTF-8文本。
有些人认为Go字符串总是UTF-8文本,但事实并非如此,正如我们在本文开头所展示的,字符串值可以包含任意字节,里面可能包含不能被解析为UTF-8编码的字节序列。
总之,Go字符串可以包含任意字节,但当我们从字符串字面量(非\xNN
表示法)构建字符串时,里面的字节序列(几乎总是)符合UTF-8编码的。
码点(Code point)、字符(character)和rune
到目前为止,我们在使用“字节(byte)”和“字符(character)”这两个词时非常小心。这部分是因为字符串包含字节,部分是因为“字符”的概念有点难以定义。Unicode标准使用术语“码点(code point,也有翻译为‘码值’的)”来指代由单个数字表示的字符。例如码点U+2318,具有十六进制数值2318,表示符号“⌘”。(有关该码点的更多信息,请参阅其Unicode页面。)
举一个更普通的例子,Unicode码点U+0061是小写拉丁字母“a”。
但是小写带重音的字母“à”呢?这也是一个字符,也是一个码点(U+00E0),但它有其他表示形式。例如,我们可以使用“组合”重音码点U+0300,并将其附加到小写字母a(码点是U+0061),来创建相同的字符“à”。通常,一个字符可以由许多不同的码点序列表示,因此也可以编码为不同的UTF-8字节序列。
因此,计算机中的字符(character)的概念是模糊的,或者至少是令人困惑的,所以我们谨慎地使用它。为了使事情变得可靠,有一些规范化的技术可以保证给定的字符总是由相同的码点表示,但这个主题偏离本文的主题太远了。稍后的博客文章将解释Go库如何解决规范化问题。
“码点”这个词有点晦涩难懂,所以Go为这个概念引入了一个较短的术语:rune。这个术语出现在库和源代码中,其含义与“码点”完全相同,还有一个有趣的补充。
Go语言将rune定义为类型int32类型的别名,因此当整数值表示码点时,程序就会很清晰。此外,你可能认为的字符常量在Go中被称为“rune常量”。例如'⌘'
的类型是rune
,值是整数0x2318
。
总之,以下是一些重点:
范围循环
除了Go源代码是UTF-8文本之外,实际上Go还有一个特殊对待UTF-8的地方,那就是在字符串上使用for range循环时。
我们已经看到了普通for循环的情况。相比之下,for range循环在每次迭代中解码一个UTF-8编码的rune。每次循环时,循环的索引是当前rune的起始位置,以字节为单位,循环的值是当前rune的Unicode码点。下面是一个使用另一种方便的Printf
函数的格式%#U
的示例,它显示了rune的Unicode码点的值及其打印输出的字符:
const nihongo = "日本語"
for index, runeValue := range nihongo {
fmt.Printf("%#U starts at byte position %d\n", runeValue, index)
}
输出显示每个Unicode码点如何占用多个字节:
U+65E5 '日' starts at byte position 0
U+672C '本' starts at byte position 3
U+8A9E '語' starts at byte position 6
[练习:将一个非法的UTF-8字节序列放入字符串中。循环的迭代会发生什么?]
库
Go的标准库为解析UTF-8文本提供了强大的支持。
如果for range循环不足以满足你的目的,那么你需要的设施很可能是由库中的包提供的。 最重要的包是unicode/utf8
,它包含用于验证、反组装(disassemble)和重新组装UTF-8字符串的辅助函数代码。这里有一个与上面的for range示例等效的程序,但使用该包中的DecodeRunInString
函数来完成这项工作。函数的返回值是rune及其宽度(以UTF-8编码的字节为单位)。
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
}
运行它以查看它是否执行相同的操作。for range循环和DecodeRunInString
函数被定义为生成完全相同的迭代序列。
你可以查看unicode/utf8
包的官方文档,了解它还提供了哪些其他功能。
结论
为了回答开头提出的问题:字符串是从字节构建的,因此对字符串进行索引会产生字节,而不是字符(character)。字符串甚至可能不包含字符(character)。事实上,“字符(character)”这一定义是模糊的,试图通过“定义字符串是由字符组成的”来解决二义性是一种错误的做法。
关于Unicode、UTF-8和多语言文本处理,还有很多话要说,但这应该写成另一篇文章。目前,我们希望你能更好地了解Go字符串的行为,尽管它们可能包含任意字节,但UTF-8是其设计的核心部分。