Go語言切片面試真題7連問

asong發表於2022-02-18

前言

哈嘍,大家好,我是asong。最近沒事在看八股文,總結了幾道常考的切片八股文,以問答的方式總結出來,希望對正在面試的你們有用~

本文題目不全,關於切片的面試真題還有哪些?歡迎評論區補充~

01. 陣列和切片有什麼區別?

Go語言中陣列是固定長度的,不能動態擴容,在編譯期就會確定大小,宣告方式如下:

var buffer [255]int
buffer := [255]int{0}

切片是對陣列的抽象,因為陣列的長度是不可變的,在某些場景下使用起來就不是很方便,所以Go語言提供了一種靈活,功能強悍的內建型別切片("動態陣列"),與陣列相比切片的長度是不固定的,可以追加元素。切片是一種資料結構,切片不是陣列,切片描述的是一塊陣列,切片結構如下:

<img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/93958be1acdb4eb8867d307e92ad1d17~tplv-k3u1fbpfcp-zoom-1.image" style="zoom:50%;" />

我們可以直接宣告一個未指定大小的陣列來定義切片,也可以使用make()函式來建立切片,宣告方式如下:

var slice []int // 直接宣告
slice := []int{1,2,3,4,5} // 字面量方式
slice := make([]int, 5, 10) // make建立
slice := array[1:5] // 擷取下標的方式
slice := *new([]int) // new一個

切片可以使用append追加元素,當cap不足是進行動態擴容。

02. 拷貝大切片一定比拷貝小切片代價大嗎?

這道題比較有意思,原文地址:Are large slices more expensive than smaller ones?

這道題本質是考察對切片本質的理解,Go語言中只有值傳遞,所以我們以傳遞切片為例子:

func main()  {
    param1 := make([]int, 100)
    param2 := make([]int, 100000000)
    smallSlice(param1)
    largeSlice(param2)
}

func smallSlice(params []int)  {
    // ....
}

func largeSlice(params []int)  {
    // ....
}

切片param2要比param11000000個數量級,在進行值拷貝的時候,是否需要更昂貴的操作呢?

實際上不會,因為切片本質內部結構如下:

type SliceHeader struct {
 Data uintptr
 Len  int
 Cap  int
}

切片中的第一個字是指向切片底層陣列的指標,這是切片的儲存空間,第二個欄位是切片的長度,第三個欄位是容量。將一個切片變數分配給另一個變數只會複製三個機器字,大切片跟小切片的區別無非就是 LenCap的值比小切片的這兩個值大一些,如果發生拷貝,本質上就是拷貝上面的三個欄位。

03. 切片的深淺拷貝

深淺拷貝都是進行復制,區別在於複製出來的新物件與原來的物件在它們發生改變時,是否會相互影響,本質區別就是複製出來的物件與原物件是否會指向同一個地址。在Go語言,切片拷貝有三種方式:

  • 使用=操作符拷貝切片,這種就是淺拷貝
  • 使用[:]下標的方式複製切片,這種也是淺拷貝
  • 使用Go語言的內建函式copy()進行切片拷貝,這種就是深拷貝,

04. 零切片、空切片、nil切片是什麼

為什麼問題中這麼多種切片呢?因為在Go語言中切片的建立方式有五種,不同方式建立出來的切片也不一樣;

  • 零切片

我們把切片內部陣列的元素都是零值或者底層陣列的內容就全是 nil的切片叫做零切片,使用make建立的、長度、容量都不為0的切片就是零值切片:

slice := make([]int,5) // 0 0 0 0 0
slice := make([]*int,5) // nil nil nil nil nil
  • nil切片

nil切片的長度和容量都為0,並且和nil比較的結果為true,採用直接建立切片的方式、new建立切片的方式都可以建立nil切片:

var slice []int
var slice = *new([]int)
  • 空切片

空切片的長度和容量也都為0,但是和nil的比較結果為false,因為所有的空切片的資料指標都指向同一個地址 0xc42003bda0;使用字面量、make可以建立空切片:

var slice = []int{}
var slice = make([]int, 0)

空切片指向的 zerobase 記憶體地址是一個神奇的地址,從 Go 語言的原始碼中可以看到它的定義:

// base address for all 0-byte allocations
var zerobase uintptr

// 分配物件記憶體
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
    if size == 0 {
        return unsafe.Pointer(&zerobase)
    }
  ...
}

05. 切片的擴容策略

這個問題是一個高頻考點,我們通過原始碼來解析一下切片的擴容策略,切片的擴容都是呼叫growslice方法,不同版本,擴容機制也有細微差距,擷取Go1.17版本部分重要原始碼:

// runtime/slice.go
// et:表示slice的一個元素;old:表示舊的slice; cap:表示新切片需要的容量;
func growslice(et *_type, old slice, cap int) slice {
    if cap < old.cap {
        panic(errorString("growslice: cap out of range"))
    }

    if et.size == 0 {
        // append should not create a slice with nil pointer but non-zero len.
        // We assume that append doesn't need to preserve old.array in this case.
        return slice{unsafe.Pointer(&zerobase), old.len, cap}
    }

    newcap := old.cap
  // 兩倍擴容
    doublecap := newcap + newcap
  // 新切片需要的容量大於兩倍擴容的容量,則直接按照新切片需要的容量擴容
    if cap > doublecap {
        newcap = cap
    } else {
    // 原 slice 容量小於 1024 的時候,新 slice 容量按2倍擴容
        if old.cap < 1024 {
            newcap = doublecap
        } else { // 原 slice 容量超過 1024,新 slice 容量變成原來的1.25倍。
            // Check 0 < newcap to detect overflow
            // and prevent an infinite loop.
            for 0 < newcap && newcap < cap {
                newcap += newcap / 4
            }
            // Set newcap to the requested cap when
            // the newcap calculation overflowed.
            if newcap <= 0 {
                newcap = cap
            }
        }
    }

  // 後半部分還對 newcap 作了一個記憶體對齊,這個和記憶體分配策略相關。進行記憶體對齊之後,新 slice 的容量是要 大於等於 老 slice 容量的 2倍或者1.25倍。
    var overflow bool
    var lenmem, newlenmem, capmem uintptr
    // Specialize for common values of et.size.
    // For 1 we don't need any division/multiplication.
    // For sys.PtrSize, compiler will optimize division/multiplication into a shift by a constant.
    // For powers of 2, use a variable shift.
    switch {
    case et.size == 1:
        lenmem = uintptr(old.len)
        newlenmem = uintptr(cap)
        capmem = roundupsize(uintptr(newcap))
        overflow = uintptr(newcap) > maxAlloc
        newcap = int(capmem)
    case et.size == sys.PtrSize:
        lenmem = uintptr(old.len) * sys.PtrSize
        newlenmem = uintptr(cap) * sys.PtrSize
        capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
        overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
        newcap = int(capmem / sys.PtrSize)
    case isPowerOfTwo(et.size):
        var shift uintptr
        if sys.PtrSize == 8 {
            // Mask shift for better code generation.
            shift = uintptr(sys.Ctz64(uint64(et.size))) & 63
        } else {
            shift = uintptr(sys.Ctz32(uint32(et.size))) & 31
        }
        lenmem = uintptr(old.len) << shift
        newlenmem = uintptr(cap) << shift
        capmem = roundupsize(uintptr(newcap) << shift)
        overflow = uintptr(newcap) > (maxAlloc >> shift)
        newcap = int(capmem >> shift)
    default:
        lenmem = uintptr(old.len) * et.size
        newlenmem = uintptr(cap) * et.size
        capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
        capmem = roundupsize(capmem)
        newcap = int(capmem / et.size)
    }
}

通過原始碼可以總結切片擴容策略:

切片在擴容時會進行記憶體對齊,這個和記憶體分配策略相關。進行記憶體對齊之後,新 slice 的容量是要 大於等於老 slice 容量的 2倍或者1.25倍,當新切片需要的容量大於兩倍擴容的容量,則直接按照新切片需要的容量擴容,當原 slice 容量小於 1024 的時候,新 slice 容量變成原來的 2 倍;原 slice 容量超過 1024,新 slice 容量變成原來的1.25倍。

上面的版本是Go語言1.17的版本,在1.16以前和1024比較是oldLen,在1.18時,又改成不和1024比較了,而是和256比較,詳細程式碼如下:

newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
  newcap = cap
} else {
  const threshold = 256
  if old.cap < threshold {
    newcap = doublecap
  } else {
    // Check 0 < newcap to detect overflow
    // and prevent an infinite loop.
    for 0 < newcap && newcap < cap {
      // Transition from growing 2x for small slices
      // to growing 1.25x for large slices. This formula
      // gives a smooth-ish transition between the two.
      newcap += (newcap + 3*threshold) / 4
    }
    // Set newcap to the requested cap when
    // the newcap calculation overflowed.
    if newcap <= 0 {
      newcap = cap
    }
  }
}

Go官方註釋說這麼做的目的是能更平滑的過渡小切片按照2倍擴容,大切片按照1.25倍擴容。

06. 引數傳遞切片和切片指標有什麼區別?

我們都知道切片底層就是一個結構體,裡面有三個元素:

type SliceHeader struct {
 Data uintptr
 Len  int
 Cap  int
}

分別表示切片底層資料的地址,切片長度,切片容量。

當切片作為引數傳遞時,其實就是一個結構體的傳遞,因為Go語言引數傳遞只有值傳遞,傳遞一個切片就會淺拷貝原切片,但因為底層資料的地址沒有變,所以在函式內對切片的修改,也將會影響到函式外的切片,舉例:

func modifySlice(s []string)  {
    s[0] = "song"
    s[1] = "Golang"
    fmt.Println("out slice: ", s)
}

func main()  {
    s := []string{"asong", "Golang夢工廠"}
    modifySlice(s)
    fmt.Println("inner slice: ", s)
}
// 執行結果
out slice:  [song Golang]
inner slice:  [song Golang]

不過這也有一個特例,先看一個例子:

func appendSlice(s []string)  {
    s = append(s, "快關注!!")
    fmt.Println("out slice: ", s)
}

func main()  {
    s := []string{"asong", "Golang夢工廠"}
    appendSlice(s)
    fmt.Println("inner slice: ", s)
}
// 執行結果
out slice:  [asong Golang夢工廠 快關注!!]
inner slice:  [asong Golang夢工廠]

因為切片發生了擴容,函式外的切片指向了一個新的底層陣列,所以函式內外不會相互影響,因此可以得出一個結論,當引數直接傳遞切片時,如果指向底層陣列的指標被覆蓋或者修改(copy、重分配、append觸發擴容),此時函式內部對資料的修改將不再影響到外部的切片,代表長度的len和容量cap也均不會被修改

引數傳遞切片指標就很容易理解了,如果你想修改切片中元素的值,並且更改切片的容量和底層陣列,則應該按指標傳遞。

07. range遍歷切片有什麼要注意的?

Go語言提供了range關鍵字用於for 迴圈中迭代陣列(array)、切片(slice)、通道(channel)或集合(map)的元素,有兩種使用方式:

for k,v := range _ { }
for k := range _ { }

第一種是遍歷下標和對應值,第二種是隻遍歷下標,使用range遍歷切片時會先拷貝一份,然後在遍歷拷貝資料:

s := []int{1, 2}
for k, v := range s {
  
}
會被編譯器認為是
for_temp := s
len_temp := len(for_temp)
for index_temp := 0; index_temp < len_temp; index_temp++ {
  value_temp := for_temp[index_temp]
  _ = index_temp
  value := value_temp
  
}

不知道這個知識點的情況下很容易踩坑,例如下面這個例子:

package main

import (
 "fmt"
)

type user struct {
 name string
 age uint64
}

func main()  {
 u := []user{
  {"asong",23},
  {"song",19},
  {"asong2020",18},
 }
 for _,v := range u{
  if v.age != 18{
   v.age = 20
  }
 }
 fmt.Println(u)
}
// 執行結果
[{asong 23} {song 19} {asong2020 18}]

因為使用range遍歷切片u,變數v是拷貝切片中的資料,修改拷貝資料不會對原切片有影響。

之前寫了一個對for-range踩坑總結,可以讀一下:面試官:go中for-range使用過嗎?這幾個問題你能解釋一下原因嗎?

總結

本文總結了7道切片相關的面試真題,切片一直是面試中的重要考點,把本文這幾個知識點弄會,應對面試官就會變的輕鬆自如。

關於切片的面試真題還有哪些?歡迎評論區補充~

好啦,本文到這裡就結束了,我是asong,我們下期見。

歡迎關注公眾號:Golang夢工廠

相關文章