Go新手别再被切片复制坑了

简介: Go新手别再被切片复制坑了

/ Go 语言切片复制完全指南 /

Go 语言中切片的复制是非常重要也比较容易让新手困惑的问题。本文将通过大量示例代码,全面介绍切片复制的相关知识,包括:

  1. 切片的结构
  2. copy()函数的用法
  3. 切片复制的本质
  4. 浅复制和深复制的区别
  5. 如何实现切片深复制
  6. copy()函数的常见用途
  7. 切片复制需要注意的几点

希望通过本文的学习,能够帮助大家深入理解 Go 语言中切片复制的各种用法和技巧,在开发中能够灵活运用。文末还准备了思考题,可以检验自己的学习效果。


1

 

1. 切片的结构

在讲解切片复制之前,我们先快速回顾下切片的结构。

切片是对数组的抽象和封装,所以切片实际上是一个包含三个字段的结构体:

type slice struct {
  array *[ ]Type 
  len   int  
  cap   int
}
  • array 指向底层数组
  • len 记录可用元素数量
  • cap 记录总容量

举个例子:

arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:3] // s引用arr的部分数据

这个切片 s 的结构大致如下:

s.array = &arr
s.len = 2
s.cap = 4

数组是值类型,但切片作为引用类型,所以切片之间赋值或传参时,只会复制引用,底层数组同一块内存被共享。


2

 

2. copy()函数用法

Go 语言内置的 copy()函数可以用于切片之间元素的复制,函数签名如下:

func copy(dst, src []Type) int

copy()会将 src 切片中的元素复制到 dst 中,复制长度以 len(src)和 len(dst)的最小值为准。它返回复制的元素个数。

使用 copy()复制切片:

s1 := []int{1, 2, 3}
s2 := make([]int, 10) 
n := copy(s2, s1)
fmt.Println(s1, s2, n) // [1 2 3] [1 2 3 0 0 0 0 0 0 0] 3

    需要注意的是,copy()会先计算 dst 的长度 l=len(dst),再计算复制长度 n=min(len(src), l)。

    再看一个例子:

    s1 := []int{1, 2, 3}
    s2 := make([]int, 2)
    n := copy(s2, s1) 
    fmt.Println(s1, s2, n) // [1 2 3] [1 2] 2

    s2 的长度只有 2,所以只复制了 s1 的前 2 个元素,n 返回 2。


    3

     

    3. 切片复制的本质

    Go 语言中切片之间复制实际上是“引用的复制”,而不是值的复制。

    也就是说,复制的是底层数组的引用,底层数组本身并没有复制。复制前后,src 和 dst 切片引用的是同一底层数组。

    s1 := []int{1, 2, 3}  
    s2 := make([]int, 3)
    copy(s2, s1) 
    fmt.Println(&s1[0], &s2[0]) // 0xc0000180a8 0xc0000180a8

    可以看到,s1 和 s2 的底层数组地址是一样的。修改 s2 会影响 s1:

    s2[0] = 100
    fmt.Println(s1) // [100 2 3]

    这种复制属于浅复制(shallow copy),类似于 C 语言中的 memcpy,只复制数组指针和相关属性。


    4

     

    4. 浅复制和深复制

    根据复制的层次,可以将切片复制分为浅复制和深复制。

    • 浅复制:只复制切片的基本数据,底层数组共享
    • 深复制:复制切片及底层数组,break 引用关系

    上面 copy()函数实现的是浅复制,如果需要深复制,需自己实现。


    4.1

     

    4.1 浅复制

    浅复制只复制切片本身,底层数组共享,修改一个切片会影响另一个:

    func main() {
      s1 := []int{1, 2, 3}
      s2 := shallowCopy(s1) // 浅复制
      s2[0] = 100
      fmt.Println(s1) // [100 2 3]
    }
    func shallowCopy(src []int) []int {
      dst := make([]int, len(src))
      copy(dst, src)
      return dst
    }

    浅复制对元素包含指针的切片也是问题:

    type User struct {
      id    int
      name  *string
    }
    func main() {
      u1 := User{1, &name}
      u2 := shallowCopy([]User{u1})
      *u2[0].name = "newName" // 修改了u1.name
    }

    4.2

     

    4.2 深复制

    深复制需要自己实现,完全 break 底层数组引用关系:

    func deepCopy(src []int) []int {
      dst := make([]int, len(src))
      for i := range src {
        dst[i] = src[i] 
      }
      return dst
    }

    这样修改 dst 不会影响到 src。

    对于包含指针的切片,需要额外处理指针指向的内容。


    5

     

    5. 切片深复制实现

    下面介绍几种实现切片深复制的方法。

    5.1

     

    5.1 手动循环赋值

    可以通过手动循环一个个元素进行深复制:

    func copyDeep(dst, src []int) {
      for i := range src {
        dst[i] = src[i] 
      }
    }

    类似 for 循环的方式也可以用于自定义类型:

    type User struct {
      id   int
      name string
    }
    func copyUserDeep(dst, src []User) {
      for i := range src {
        dst[i].id = src[i].id
        dst[i].name = src[i].name
      }
    }

    手动循环虽然稍微繁琐,但是性能和可控性较好。

    5.2

     

    5.2 利用反射

    Go 语言反射可以自动深复制任意类型,但是需要注意反射带来的性能损耗:

    import "reflect"
    func copyDeep(dst, src interface{}) {
      dv := reflect.ValueOf(dst).Elem()
      sv := reflect.ValueOf(src).Elem()
      for i := 0; i < sv.NumField(); i++ {
        fd := dv.Field(i)
        if fd.CanSet() {
          fd.Set(sv.Field(i))
        }
      }
    }

    使用时:

    var s1 []int = []int{1, 2, 3}
    s2 := make([]int, 3)
    copyDeep(&s2, &s1)

    反射的威力在于可以处理任意类型,但是需要注意反射带来的额外性能损耗。

    5.3

     

    5.3 利用 encoding/gob

    gob 是一个二进制数据序列化的格式,可以用于深度 Copy:

    import (
      "bytes"
      "encoding/gob"
    )
    func copyDeep(src, dst interface{}) error {
      buff := new(bytes.Buffer)
      enc := gob.NewEncoder(buff)
      dec := gob.NewDecoder(buff)
      if err := enc.Encode(src); err != nil {
        return err
      }
      if err := dec.Decode(dst); err != nil {
        return err
      }
      return nil
    }

    使用 encoding/gob 进行深拷贝也有一定的性能损耗。

    5.4

     

    5.4 利用第三方库

    如果需要频繁深拷贝,可以考虑使用一些第三方库,如:

    • github.com/jinzhu/copier
    • github.com/ulule/deepcopier

    这些库利用反射实现泛型深拷贝,并进行了性能优化,会更高效。


    6

     

    6. copy()函数的常见用途

    copy()作为切片浅复制的主要函数,使用场景还是很多的,主要有:

    • 切片扩容时复制老数据
    • 从一个切片截取部分元素到新切片
    • 切片重组,两个切片交换元素
    • 将字节流复制到字节切片缓冲
    • 文件拷贝等

    6.1

     

    6.1 切片扩容

    Go 语言中切片扩容时,常用 copy()来复制老数据:

    func appendSlice(slice []int) []int {
      newSlice := make([]int, len(slice)+1) 
      copy(newSlice, slice)
      return newSlice
    }

    6.2

     

    6.2 截取切片

    从一个大切片截取需要的部分到新切片:

    bytes := []byte("Hello World")
    hello := make([]byte, 5)
    copy(hello, bytes[:5]) 
    world := make([]byte, 5)
    copy(world, bytes[6:])

    6.3

     

    6.3 切片重组

    两个切片可以通过 copy 相互交换元素:

    s1 := []int{1, 2, 3}
    s2 := []int{4, 5}
    copy(s1, s2) 
    copy(s2, s1)

    交换后 s1=[4,5,3],s2=[1,2]。

    6.4

     

    6.4 字节流复制

    IO 操作读取字节流时,常用 copy()写入字节切片缓冲:

    buf := make([]byte, 1024)
    for {
      n, err := r.Read(buf)
      // 使用buf前N字节
    }

    6.5

     

    6.5 文件复制

    利用 copy()可以实现高效的文件拷贝:

    func CopyFile(dst, src string) error {
      r, w := os.Open(src), os.Create(dst)
      defer r.Close(); defer w.Close()
      buf := make([]byte, 1024*1024)
      for {
        n, err := r.Read(buf)
        if err != nil {
          if err == io.EOF {
            break
          }
          return err  
        }
        if n == 0 {
          break
        }
        w.Write(buf[:n])
      }
      return nil
    }

    7

     

    7. 注意事项

    最后需要注意几点:

    • copy()要求 dst 必须提前分配内存,否则会 panic
    • 指针或包含指针的切片只会复制指针,不会深复制目标对象
    • 多次复制切片会造成 GC 负担,尽量复用内存减少不必要的复制


    8

     

    思考题

    • 描述下切片的结构包含哪些字段
    • copy()函数签名是什么
    • 切片复制的本质是什么
    • 如何实现切片的深复制
    • copy()函数有哪些常见的使用场景

    欢迎在评论区分享你的思考和见解。


    目录
    相关文章
    |
    16天前
    |
    存储 Go
    |
    17天前
    |
    Java Go 数据处理
    go语言使用切片而非数组
    【10月更文挑战第18天】
    9 1
    |
    5月前
    |
    Go
    Go 中使用切片来实现动态数组的功能
    Go 中使用切片来实现动态数组的功能
    |
    15天前
    |
    Go
    |
    5月前
    |
    Go
    go语言数组与切片
    go语言数组与切片
    |
    29天前
    |
    存储 安全 Go
    Go语言切片:从入门到精通的深度探索###
    本文深入浅出地剖析了Go语言中切片(Slice)这一核心概念,从其定义、内部结构、基本操作到高级特性与最佳实践,为读者提供了一个全面而深入的理解。通过对比数组,揭示切片的灵活性与高效性,并探讨其在并发编程中的应用优势。本文旨在帮助开发者更好地掌握切片,提升Go语言编程技能。 ###
    |
    2月前
    |
    Go 索引
    Go to Learn Go之切片
    Go to Learn Go之切片
    28 1
    |
    2月前
    |
    编译器 Go 索引
    Go数组、多维数组和切片(动态数组),及常用函数len(),cap(),copy(),append()在切片中的使用
    本文介绍了Go语言中数组、多维数组和切片(动态数组)的基本概念和操作,包括数组的定义、初始化、访问,多维数组的定义和访问,以及切片的创建、使用和扩容。同时,还讲解了切片中常用的函数len()、cap()、copy()和append()的使用方法。
    |
    3月前
    |
    Go
    Go 1.21的新特性: 切片和映射
    Go 1.21的新特性: 切片和映射
    |
    3月前
    |
    存储 Go
    go 切片长度与容量的区别
    go 切片长度与容量的区别