Go语言之父详述切片与数组的不同

Kevin · · 532 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

0.jpeg
切片是Go 语言核心的数据结构,然而刚接触 Go 的程序员经常在切片的工作方式和行为表现上被绊倒。比如,明明说切片是引用类型但在函数内对其做的更改有时候却保留不下来,有时候却可以。究其原因是因为我们很多人用其他语言的思维来尝试猜测 Go 语言中切片的行为,切片这个内置类型在 Go 语言底层有其单独的类型定义,而不是我们通常理解的其他语言中数组的概念。

文章翻译自罗伯·派克发布在 Go Blog 中的文章,文中详述了切片是如何被设计出来的以及其与数组的关联和区别,以及内置append函数的实现细节。虽篇幅很长,还是建议认证读完,尤其是关于切片的设计和append函数实现的部分,理解了“切片头”后很多的切片行为就自然而然能够理解。

Rob·Pike
2013 年 9 月 26 日
原文地址:https://blog.golang.org/slices

介绍

过程编程语言最常见的特征之一就是数组的概念。数组看似简单,但是将数组添加到语言时必须回答许多问题,例如:

  • 数组使用固定尺寸还是可变尺寸?
  • 尺寸是数组类型的一部分吗?
  • 多维数组是什么样的?
  • 空数组有意义吗?

这些问题的答案会影响数组是否只是语言的一个普通的功能还是其设计的核心部分。

在 Go 的早期开发中,在感觉到设计正确之前,我们花了大约一年的时间决定对这些问题的答案。非常关键的一步是我们引入了切片,它基于固定大小的数组构建,以提供灵活,可扩展的数据结构。然而,直到今天,刚接触 Go 的程序员经常在切片的工作方式上被绊倒,这也许是因为其他语言的经验固化了他们的思维。

在这篇文章中,我们将尝试消除混乱。我们将通过构建知识片段来解释 append 内置函数的工作原理以及它如此工作的原因。

数组

数组是 Go 中重要的构建块,但就像建筑物的基础一样,它们通常隐藏在可见的组件下。在继续介绍切片的更有趣,更强大和更重要的概念之前,我们必须简短地谈论一下数组。

在 Go 程序中并不经常看到数组,因为数组的大小是数组类型的一部分,这限制了数组的表达能力。

声明数组如下

var buffer [256]byte

声明数组变量 buffer,其中包含 256 个字节。 buffer 的类型包括其大小,[256] byte。 一个包含 512 个字节的数组将具有不同的类型 [512] byte

与数组关联的数据就是:元素数组。从原理上讲,我们的 buffer 在内存中看起来像这样,

buffer: byte byte byte ... 256 个 ... byte byte byte

也就是说,该变量保存 256 个字节的数据,仅此而已。我们可以通过使用熟悉的索引语法 buffer [0]buffer [1]buffer [255] 等访问其元素。 (索引范围 0 到 255 涵盖 256 个元素。) 尝试使用该范围之外的值索引数组 buffer 会使程序崩溃。

内置函数 len 的回数组或切片以及其他一些数据类型的元素数量。对于数组,很明显 len 会返回什么。在我们的示例中,len(buffer) 返回固定值 256。

数组有自己的一席之地 (例如,它们很好地表示了转换矩阵),但是它们在 Go 中最常见的应用目的是保留切片的存储空间。

Slices:切片头

切片是执行操作的地方,但是要充分利用它们,开发者必须准确了解它们的含义和作用。

切片是一种数据结构,描述与切片变量本身分开存储的数组的一段连续的部分,。 切片不是数组。切片描述一块数组。

用上节给定的数组变量 buffer,我们可以创建一个描述了数组第 100 个元素到第 150 个元素的切片(准确地说是包含第 100 个元素到 149 个元素):

var slice []byte = buffer[100:150]

在该代码段中,我们使用了完整的变量声明。变量slice 的类型为 [] byte 的 “字节切片”,并通过从名为 buffer 的数组切片第 100 个元素 (包括) 到第 150 个元素 (不包括) 来初始化。更惯用的语法是忽略类型,类型由初始化表达式设置:

var slice = buffer[100:150]

在函数内部,我们可以使用简短声明形式,

slice := buffer[100:150]

切片变量到底是什么?现在将 slice 看作是一个具有两个元素的小数据结构:长度和指向数组元素的指针。你可以认为它是在底层像这样被构建的:

type sliceHeader struct {
    Length        int
    ZerothElement *byte
}

slice := sliceHeader{
    Length:        50,
    ZerothElement: &buffer[100],
}

当然,这只是一个为了说明举的例子。尽管此代码段说明了 sliceHeader 结构对于程序员是不可见的,并且元素指针的类型取决于元素的类型,但这给出了切片机制大体上的概念。

到目前为止,我们已经对数组使用了切片操作,但是我们也可以对切片进行切片操作,如下所示:

slice2 := slice[5:10]

和之前一样,此操作将创建一个新的切片,在这种情况下,新切片将使用原始切片的元素 5 至 9,也就是原始数组的元素 105 至 109。 slice2 变量底层的 sliceHeader 结构如下所示:

slice2 := sliceHeader{
    Length:        5,
    ZerothElement: &buffer[105],
}

请注意,此标头仍指向存储在 buffer 变量中的相同底层数组。

我们还可以重切片,也就是说对切片进行切片操作,然后将结果存储回原始切片结构中。在执行下面的切片操作后

slice = slice[5:10]

slice 变量的 sliceHeader 结构看起来和 slice2 变量的结构一样。在使用Go 的过程中你将会看到重切片会被经常使用,例如截断切片。下面的语句删除切片的第一个和最后一个元素:

slice = slice[1:len(slice)-1]

[练习:在上面的赋值之后,写出 sliceHeader 结构的外观。]

你将经常会听到经验丰富的 Go 程序员谈论 “切片标头”,因为这实际上是存储在切片变量中的内容。例如,当您调用一个将切片作为参数的函数时,例如bytes.IndexRune,该标头就是传递给该函数的内容。在这次调用中

slashPos := bytes.IndexRune(slice, '/')

传递给 IndexRune 函数的 slice 参数实际上是一个 “切片标头”。

切片头中还有一个数据项,我们将在下面讨论,但是首先让我们看看在使用切片进行编程时,切片 头的存在意味着什么。

将切片传递给函数
重要的是要理解,即使切片包含指针,它本身也是一个值。在幕后,它是一个结构体值,包含一个指针和一个长度。它不是结构体的指针。

这很重要。

在上一个示例中,当我们调用IndexRune 时,它传递了切片头的副本。这种行为具有重要的影响。

考虑下面这个简单的函数

func AddOneToEachElement(slice []byte) {
    for i := range slice {
        slice[i]++
    }
}

它确实做到了其名称暗示的那样,对切片的索引进行迭代 (使用 for range 循环),自增每个元素。

尝试一下:

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)
}

(如果想探究,可以编辑并重新执行这些可运行的代码段。)

尽管切片头是按值传递的,但标头包含指向数组元素的指针,因此原始切片标头和传递给函数的标头副本都描述了同一数组。所以,当函数返回时,可以通过原始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参数的内容可以由函数修改,但是它的切片标头不能。调用该函数不会修改slice 变量中存储的长度,因为传给该函数的是切片头的副本 (而不是原始头)。因此,如果我们要编写一个修改标头的函数,则必须像在此所做的一样,将其作为结果参数返回。 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") // 将字符串转换为 path 类型
    pathName.TruncateAtFinalSlash()
    fmt.Printf("%s.", pathName)
}

如果运行此示例,您将看到它可以正常工作,并在调用的函数中更新切片。

[练习:将接收器的类型更改为值而不是指针,然后再次运行。解释发生了什么。]

另一方面,如果我们想为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.", pathName)
}

在这里,ToUpper 方法在中为range 循环使用两个变量来捕获索引和切片元素。这种形式的循环避免在体内多次写入p[i]

[练习:转换 ToUpper 方法以使用指针接收器,并查看其行为是否改变。]

[高级练习:转换 ToUpper 方法以处理 Unicode 字母,而不仅仅是ASCII。]

容量

下面这个函数为其整型切片参数扩充一个元素:

func Extend(slice []int, element int) []int {
    n := len(slice)
    slice = slice[0 : n+1]
    slice[n] = element
    return slice
}

(为什么它需要返回修改后的切片?) 现在使用 Extend 函数并运行下面的程序:

func main() {
    var iBuffer [10]int
    slice := iBuffer[0:0]
    for i := 0; i < 20; i++ {
        slice = Extend(slice, i)
        fmt.Println(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 字段等于基础数组的长度减去切片的第一个元素指向的数组元素在数组中的索引 (在本例中切片第一个元素对应的数组元素的索引为 0)。如果要查询切片的容量,请使用内置函数 cap

if cap(slice) == len(slice) {
    fmt.Println("slice is full!")
}

Make 函数

如果我们想将切片扩大到超出其capacity怎么办?实际上你办不到!根据定义,capacity 是切片增长的极限。但是,您可以通过分配一个新数组,复制数据到新数组并修改切片以描述新的数组来获得等效的结果。

让我们从分配开始。我们可以使用 new 内置函数分配一个更大的数组,然后对结果进行切片,但是使用 make 内置函数更简单。它分配一个新数组并创建一个切片头来描述它。 make函数采用三个参数:切片的类型,初始长度和容量,容量是 make分配的用来保存切片数据的数组的长度。下面make函数的调用,可以创建一个长度为 10 的切片,底层数组还有 5 个余量 (15-10),这可以通过运行它看到:

slice := make([]int, 10, 15)
fmt.Printf("len: %d, cap: %d.", len(slice), cap(slice))

下面的代码片段使我们的 int 切片的容量增加了一倍,但长度保持不变:

slice := make([]int, 10, 15)
fmt.Printf("len: %d, cap: %d.", 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.", len(slice), cap(slice))

运行上面的代码后,slice 在需要再次分配新的底层数组之前拥有了更多空间去扩充。

创建切片时,长度和容量通常是相同的。内置的make支持此常见情况的简写形式。 length 参数值默认为capacity值,因此在使用make函数时您可以省略capacity将它们设置为相同的值。像下面这样:

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 函数将值插入到切片指定的索引位置上
//插入的位置必须在范围内。
//切片必须为新元素留出空间。
func Insert(slice []int, index, value int) []int {
    //将切片增加一个元素。
    slice = slice[0 : len(slice)+1]
    //使用复制将切片的上部移开,并留出一个位置。
    copy(slice[index+1:], slice[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) {
        // 切片已满,必须扩充容量
        // 我们将其容量加倍并加1,因此如果原来大小为零,仍能扩展切片容量。
        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.", 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将项目追加到切片
//第一个版本:只是循环调用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 := []int{0, 1, 2, 3, 4}

Append 很有意思的另一个原因是,我们不仅可以像源切片追加元素,还可以在调用`Append
时使用...语法将切片拆分成函数的实参。这样我们就能用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 函数的,一次将切片数据移动到新分配的内存中,然后将附加项复制到旧数据的末尾。

尝试一下;新代码片段的行为与以前相同:

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

下面是一些与print语句混合的线性程序。试试看,编辑并探究结果

// 创建两个初始切片
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)

//复制(int的)切片。
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 页面。上,有更多的appendcopy 和其他使用切片方式的示例。

Nil

顺便说一句,有了我们新学到的知识,我们可以看到nil切片的表示是什么。自然地,它是切片标头的零值:

sliceHeader{
    Length:        0,
    Capacity:      0,
    ZerothElement: nil,
}

或者这么表示

sliceHeader{}

关键的细节是切片头中元素指针也是nil,而由下面语句创建的切片

array[0:0]

长度为零 (甚至容量为零),但其指针不是nil,因此它不是nil切片。

需要清楚的是,空切片可以增长 (假设其容量为非零),但是nil切片没有数组可以放入值,甚至不能增长以容纳一个元素。

就是说,nil切片在功能上等效于零长度切片,即使它没有指向任何内容。它的长度为零,通过分配新数组可以用append 函数向其追加元素。例如,请查看上面的单线程序,该单线程序通过附加到nil切片来复制切片。

译注:说的是下面这个程序

 //复制(int的)切片。
slice3 := append([]int(nil), slice...)
fmt.Println("Copy a slice:", slice3)

字符串

现在简要介绍一下切片上下文中的Go中的字符串。

字符串实际上非常简单:它们只是只读的字节切片,而切在语言层面还提供了一些额外的语法支持。

因为它们是只读的,所以不需要容量 (不能增加它们),但是对于大多数情况下,您可以将它们像只读的字节切片一样对待他们。

首先,我们可以为它们索引字符串以访问各个字节:

slash := "/usr/ken"[0] //产生字节值'/'

我们可以对字符串进行切片以获取子字符串:

usr := "/usr/ken"[0:4] // 产生字符串"/usr"

现在,当我们切成字符串时,幕后发生的事情应该很容易理解了。

我们还可以用一个普通的字节切片,通过简单的转换从中创建一个字符串:

str := string(slice)

反之亦然:

slice := []byte(usr)

字符串底层的数组从视野中被隐藏掉了;除了通过字符串,无法访问其内容。这意味着当我们执行这些转换中的任何一个时,都必须复制该数组。当然,Go 会处理好这一点,因此您不必这样做。在这些转换中的任何一个之后,对字节片下面的数组的修改不会影响相应的字符串。

这种类似切片的字符串设计的一个重要结果是创建子字符串非常高效。所有需要做的就是创建一个两个字的字符串标头。由于字符串是只读的,因此原始字符串和切片操作产生的字符串可以安全地共享同一数组。

历史记录:最早的字符串实现总是分配的,但是当将切片添加到语言时,它们提供了有效的字符串处理模型。结果一些基准测试获得了巨大的加速。

当然,字符串还有更多的东西,单独的博客文章可以更深入地了解它们。

结论

理解切片的工作原理,有助于了解切片的实现方式。切片有一个小的数据结构,即切片标头,它是与 slice 变量关联的项目,并且该标头描述了单独分配的数组的一部分。当我们传递切片值时,将标头将会被复制,但始终都会指向它(译注:源标头)指向的数组。

一旦了解了它们的工作原理,切片不仅变得易于使用,而且变得强大而富有表现力,尤其是在 copyappend内置函数的帮助下。

阅读更多

Go中有关切片的管间中可以找到很多东西。如前所述,“Slice Tricks” Wiki 页面有很多示例。

有很多可用的资料,但是学习切片的最佳方法是使用切片。

tWbHIMFsM3.png


有疑问加站长微信联系(非本文作者)

本文来自:Segmentfault

感谢作者:Kevin

查看原文:Go语言之父详述切片与数组的不同

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

532 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传