Skip to content

string

string est un type de données de base très courant en Go, c'est aussi le premier type de données que j'ai rencontré en Go.

go
package main

import "fmt"

func main() {
  fmt.Println("hello,world!")
}

Je suis sûr que la plupart des gens ont tapé ce code lorsqu'ils ont commencé avec Go. Dans builtin/builtin.go, il y a une description simple de string :

go
// string is the set of all strings of 8-bit bytes, conventionally but not
// necessarily representing UTF-8-encoded text. A string may be empty, but
// not nil. Values of string type are immutable.
type string string

De ce qui précède, on peut tirer les informations suivantes :

  • string est un ensemble d'octets de 8 bits

  • Le type string est généralement encodé en UTF-8

  • string peut être vide, mais ne peut pas être nil

  • string est immuable

Ces caractéristiques sont bien connues de ceux qui utilisent Go régulièrement. Voyons maintenant quelque chose de différent.

Structure

En Go, une chaîne de caractères est représentée à l'exécution par la structure runtime.stringStruct. Cependant, elle n'est pas exposée publiquement. On peut utiliser reflect.StringHeader comme alternative.

TIP

Bien que StringHeader ait été déprécié dans la version go.1.21, elle reste très intuitive. Nous continuerons à l'utiliser dans ce qui suit. Cela n'affecte pas la compréhension. Pour plus de détails, voir Issues · golang/go (github.com).

go
// runtime/string.go
type stringStruct struct {
  str unsafe.Pointer
  len int
}

// reflect/value.go
type StringHeader struct {
  Data uintptr
  Len  int
}

Les champs sont expliqués comme suit :

  • Data, un pointeur vers l'adresse mémoire de départ de la chaîne
  • Len, le nombre d'octets de la chaîne

Voici un exemple d'accès à l'adresse d'une chaîne via un pointeur unsafe :

go
func main() {
  str := "hello,world!"
  h := *((*reflect.StringHeader)(unsafe.Pointer(&str)))
  for i := 0; i < h.Len; i++ {
    fmt.Printf("%s ", string(*((*byte)(unsafe.Add(unsafe.Pointer(h.Data), uintptr(i)*unsafe.Sizeof(str[0]))))))
  }
}

Cependant, Go recommande maintenant d'utiliser unsafe.StringData à la place :

go
func main() {
  str := "hello,world!"
  ptr := unsafe.Pointer(unsafe.StringData(str))
  for i := 0; i < len(str); i++ {
    fmt.Printf("%s ", string(*((*byte)(unsafe.Add(ptr, uintptr(i)*unsafe.Sizeof(str[0]))))))
  }
}

Les deux produisent le même résultat :

h e l l o , w o r l d !

Une chaîne de caractères est essentiellement une zone d'adresses mémoire contiguës. Chaque adresse stocke un octet. En d'autres termes, c'est un tableau d'octets. Le résultat obtenu via la fonction len est le nombre d'octets, pas le nombre de caractères dans la chaîne. C'est particulièrement vrai lorsque les caractères de la chaîne ne sont pas des caractères ASCII.

string lui-même n'occupe qu'une très petite quantité de mémoire, c'est-à-dire un pointeur vers les données réelles. Ainsi, le coût de transmission d'une chaîne est très faible. À mon avis, comme elle ne détient qu'une référence mémoire, si elle pouvait être modifiée arbitrairement, il serait difficile de savoir plus tard si l'ancienne référence pointe toujours vers les données souhaitées (à moins d'utiliser la réflexion ou le paquet unsafe), sauf si l'utilisateur des anciennes données n'aura plus jamais besoin de cette chaîne après utilisation. Un autre avantage est la sécurité concurrente native : personne ne peut la modifier dans des conditions normales.

Concaténation

La syntaxe de concaténation de chaînes est la suivante, en utilisant directement l'opérateur + :

go
var (
    hello = "hello"
    dot   = ","
    world = "world"
    last  = "!"
)
str := hello + dot + world + last

L'opération de concaténation est effectuée à l'exécution par la fonction runtime.concatstrings. Pour une concaténation de littéraux comme ci-dessous, le compilateur déduit directement le résultat :

go
str := "hello" + "," + "world" + "!"
_ = str

En regardant le code assembleur généré, on peut voir le résultat. Voici un extrait :

LEAQ    go:string."hello,world!"(SB), AX
MOVQ    AX, main.str(SP)

Il est clair que le compilateur la considère comme une chaîne complète. Sa valeur est déterminée lors de la compilation et ne sera pas concaténée par runtime.concatstrings à l'exécution. Seule la concaténation de variables de type chaîne sera effectuée à l'exécution. La signature de la fonction est la suivante, elle reçoit un tableau d'octets et un slice de chaînes :

go
func concatstrings(buf *tmpBuf, a []string) string

Lorsque le nombre de variables de chaîne à concaténer est inférieur à 5, les fonctions suivantes sont utilisées à la place (conjecture personnelle : avec des paramètres et des variables anonymes, ils sont tous stockés sur la pile, ce qui est plus facile à gérer par le GC que les slices créés à l'exécution ?). Bien qu'ils finissent par appeler concatstrings pour effectuer la concaténation.

go
func concatstring2(buf *tmpBuf, a0, a1 string) string {
  return concatstrings(buf, []string{a0, a1})
}

func concatstring3(buf *tmpBuf, a0, a1, a2 string) string {
  return concatstrings(buf, []string{a0, a1, a2})
}

func concatstring4(buf *tmpBuf, a0, a1, a2, a3 string) string {
  return concatstrings(buf, []string{a0, a1, a2, a3})
}

func concatstring5(buf *tmpBuf, a0, a1, a2, a3, a4 string) string {
  return concatstrings(buf, []string{a0, a1, a2, a3, a4})
}

Voyons maintenant ce que fait la fonction concatstrings :

go
func concatstrings(buf *tmpBuf, a []string) string {
  idx := 0
  l := 0
  count := 0
  for i, x := range a {
    n := len(x)
    // Ignorer les chaînes de longueur 0
    if n == 0 {
      continue
    }
    // Débordement numérique lors du calcul
    if l+n < l {
      throw("string concatenation too long")
    }
    l += n
    // Comptage
    count++
    idx = i
  }
  // Retourner une chaîne vide si pas de chaînes
  if count == 0 {
    return ""
  }

  // Si une seule chaîne, la retourner directement
  if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
    return a[idx]
  }
  // Allouer de la mémoire pour la nouvelle chaîne
  s, b := rawstringtmp(buf, l)
  for _, x := range a {
        // Copie
    copy(b, x)
        // Troncature
    b = b[len(x):]
  }
  return s
}

La première chose faite est de calculer la longueur totale et le nombre de chaînes à concaténer. Ensuite, la mémoire est allouée en fonction de la longueur totale. La fonction rawstringtmp retourne une chaîne s et un slice d'octets b. Bien que leur longueur soit déterminée, ils n'ont aucun contenu, car ce sont essentiellement deux pointeurs vers la nouvelle adresse mémoire. Le code d'allocation de mémoire est le suivant :

go
func rawstring(size int) (s string, b []byte) {
    // Pas de type spécifié
  p := mallocgc(uintptr(size), nil, false)
    // La mémoire est allouée mais vide
  return unsafe.String((*byte)(p), size), unsafe.Slice((*byte)(p), size)
}

La chaîne s retournée est pour une représentation pratique, le slice d'octets b est pour faciliter la modification de la chaîne. Ils pointent tous deux vers la même adresse mémoire.

go
for _, x := range a {
    // Copie
    copy(b, x)
    // Troncature
    b = b[len(x):]
}

La fonction copy appelle à l'exécution runtime.slicecopy. Ce qu'elle fait est de copier directement la mémoire de src vers l'adresse de dst. Une fois toutes les chaînes copiées, le processus de concaténation est terminé. Si les chaînes copiées sont très volumineuses, ce processus sera assez gourmand en performances.

Conversion

Comme mentionné précédemment, une chaîne de caractères ne peut pas être modifiée. Si on essaie de la modifier, même la compilation échouera. Go signalera l'erreur suivante :

go
str := "hello" + "," + "world" + "!"
str[0] = '1'
cannot assign to string (neither addressable nor a map index expression)

Pour modifier une chaîne, il faut d'abord la convertir en slice d'octets []byte. L'utilisation est simple :

go
bs := []byte(str)

En interne, cela appelle la fonction runtime.stringtoslicebyte. Sa logique est très simple, comme le montre le code :

go
func stringtoslicebyte(buf *tmpBuf, s string) []byte {
  var b []byte
  if buf != nil && len(s) <= len(buf) {
    *buf = tmpBuf{}
    b = buf[:len(s)]
  } else {
    b = rawbyteslice(len(s))
  }
  copy(b, s)
  return b
}

Si la longueur de la chaîne est inférieure à la longueur du tampon, on retourne directement le slice d'octets du tampon. Cela permet d'économiser de la mémoire lors de la conversion de petites chaînes. Sinon, une mémoire de taille équivalente à la chaîne est allouée, puis la chaîne est copiée vers la nouvelle adresse mémoire. La fonction rawbyteslice(len(s)) fait la même chose que la fonction rawstring précédente : allouer de la mémoire.

De même, un slice d'octets peut être facilement converti en chaîne syntaxiquement :

go
str := string([]byte{'h','e','l','l','o'})

En interne, cela appelle la fonction runtime.slicebytetostring, également facile à comprendre :

go
func slicebytetostring(buf *tmpBuf, ptr *byte, n int) string {
  if n == 0 {
    return ""
  }

  if n == 1 {
    p := unsafe.Pointer(&staticuint64s[*ptr])
    if goarch.BigEndian {
      p = add(p, 7)
    }
    return unsafe.String((*byte)(p), 1)
  }

  var p unsafe.Pointer
  if buf != nil && n <= len(buf) {
    p = unsafe.Pointer(buf)
  } else {
    p = mallocgc(uintptr(n), nil, false)
  }
  memmove(p, unsafe.Pointer(ptr), uintptr(n))
  return unsafe.String((*byte)(p), n)
}

D'abord, les cas particuliers où la longueur du slice est 0 ou 1 sont traités. Dans ces cas, aucune copie de mémoire n'est nécessaire. Ensuite, si la longueur est inférieure à celle du tampon, on utilise la mémoire du tampon, sinon on alloue une nouvelle mémoire. Enfin, on utilise la fonction memmove pour copier directement la mémoire. La mémoire copiée n'a aucun lien avec la mémoire source, donc elle peut être modifiée librement.

Il est important de noter que les deux méthodes de conversion ci-dessus nécessitent une copie de mémoire. Si la mémoire à copier est très volumineuse, l'impact sur les performances sera important. Dans la version go1.20, le paquet unsafe a ajouté les fonctions suivantes :

go
// Prend un pointeur de type vers une adresse mémoire et une longueur de données, retourne son expression sous forme de slice
func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType

// Prend un slice, obtient un pointeur vers son tableau sous-jacent
func SliceData(slice []ArbitraryType) *ArbitraryType

// Selon l'adresse et la longueur passées, retourne une chaîne
func String(ptr *byte, len IntegerType) string

// Prend une chaîne, retourne son adresse mémoire de départ, mais l'octet retourné ne peut pas être modifié
func StringData(str string) *byte

En particulier les fonctions String et StringData, elles n'impliquent pas de copie de mémoire et peuvent également effectuer la conversion. Cependant, il faut noter que leur utilisation nécessite de s'assurer que les données sont en lecture seule et ne seront pas modifiées ultérieurement, sinon la chaîne changera. Regardons l'exemple suivant :

go
func main() {
  bs := []byte("hello,world!")
  s := unsafe.String((*byte)(unsafe.SliceData(bs)), len(bs))
  bs[0] = 'b'
  fmt.Println(s)
}

D'abord, on obtient l'adresse du tableau sous-jacent du slice d'octets via SliceData, puis on obtient son expression sous forme de chaîne via String. Ensuite, en modifiant directement le slice d'octets, la chaîne change également. Cela va clairement à l'encontre de l'objectif des chaînes. Regardons un autre exemple :

go
func main() {
  str := "hello,world!"
  bytes := unsafe.Slice(unsafe.StringData(str), len(str))
  fmt.Println(bytes)
    // fatal
  bytes[0] = 'b'
  fmt.Println(str)
}

Après avoir obtenu l'expression sous forme de slice de la chaîne, si on essaie de modifier le slice d'octets, le programme plantera directement. Voyons ce qui change avec une autre façon de déclarer une chaîne :

go
func main() {
  var str string
  fmt.Scanln(&str)
  bytes := unsafe.Slice(unsafe.StringData(str), len(str))
  fmt.Println(bytes)
  bytes[0] = 'b'
  fmt.Println(str)
}
hello,world!
[104 101 108 108 111 44 119 111 114 108 100 33]
bello,world!

Le résultat montre que la modification a réussi. La raison du plantage précédent est que la variable str stockait un littéral de chaîne. Les littéraux de chaîne sont stockés dans le segment de données en lecture seule, pas dans le tas ou la pile. Cela empêche fondamentalement la possibilité qu'une chaîne déclarée comme littéral soit modifiée ultérieurement. Pour une variable de chaîne ordinaire, elle peut techniquement être modifiée, mais cette syntaxe n'est pas autorisée par le compilateur. En résumé, utiliser les fonctions unsafe pour les conversions de chaînes n'est pas sûr, à moins de pouvoir garantir que les données ne seront jamais modifiées.

Parcours

go
s := "hello world!"
for i, r := range s {
  fmt.Println(i, r)
}

Pour gérer les caractères multi-octets, on utilise généralement une boucle for range pour parcourir une chaîne. Lorsqu'on utilise for range pour parcourir une chaîne, le compilateur développe pendant la compilation un code sous la forme suivante :

go
ha := s
for hv1 := 0; hv1 < len(ha); {
    hv1t := hv1
    hv2 := rune(ha[hv1])
    // Vérifier si c'est un caractère mono-octet
    if hv2 < utf8.RuneSelf {
        hv1++
    } else {
        hv2, hv1 = decoderune(ha, hv1)
    }
    i, r = hv1t, hv2
  // Corps de la boucle
}

Dans le code développé, la boucle for range est remplacée par une boucle for classique. Dans la boucle, on vérifie si l'octet courant est un caractère mono-octet. Si c'est un caractère multi-octet, on appelle la fonction runtime.decoderune pour obtenir son encodage complet, puis on l'assigne à i et r. Une fois le traitement terminé, on passe à l'exécution du corps de la boucle défini dans le code source.

Le travail de construction du code intermédiaire est effectué par la fonction walkRange dans cmd/compile/internal/walk/range.go. Elle gère également tous les types qui peuvent être parcourus par for range. Nous ne développerons pas davantage ici. Si vous êtes intéressé, vous pouvez l'explorer par vous-même.

Golang by www.golangdev.cn edit