Skip to content

string

string es un tipo de datos básico muy común en Go, y fue el primer tipo de datos que encontré en el lenguaje Go.

go
package main

import "fmt"

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

Seguramente la mayoría de las personas han escrito este código cuando comenzaron a aprender Go. En builtin/builtin.go hay una descripción simple sobre 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 esta descripción se obtiene la siguiente información:

  • string es un conjunto de bytes de 8 bits
  • El tipo string normalmente representa texto codificado en UTF-8
  • string puede estar vacío, pero nunca es nil
  • string es inmutable

Estas características deberían ser bien conocidas para quienes usan Go frecuentemente. A continuación veremos algo diferente.

Estructura

En Go, una cadena se representa en tiempo de ejecución por la estructura runtime.stringStruct, aunque no se expone externamente. Como alternativa, se puede usar reflect.StringHeader.

TIP

Aunque StringHeader fue obsoleto en la versión go1.21, sigue siendo muy intuitivo, por lo que se usará para la explicación a continuación. Esto no afecta la comprensión. Para más detalles ver 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
}

Los campos se explican de la siguiente manera:

  • Data: es un puntero a la dirección de inicio de la memoria de la cadena
  • Len: número de bytes de la cadena

A continuación se muestra un ejemplo de acceso a la dirección de una cadena mediante un puntero 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]))))))
  }
}

Sin embargo, Go ahora recomienda usar unsafe.StringData como reemplazo:

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

Ambas salidas son iguales:

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

Una cadena es esencialmente un área de memoria continua, donde cada dirección almacena un byte. En otras palabras, es un array de bytes. El resultado de usar la función len es el número de bytes, no el número de caracteres en la cadena. Esto es especialmente cierto cuando los caracteres en la cadena no son caracteres ASCII.

string en sí mismo ocupa muy poca memoria, solo un puntero a los datos reales. Esto hace que pasar una cadena tenga un costo muy bajo. En mi opinión, como solo mantiene una referencia a la memoria, si pudiera ser modificada libremente, sería difícil saber si los datos originales apuntados siguen siendo los deseados (ya sea usando reflexión o el paquete unsafe), a menos que los usuarios de los datos antiguos nunca necesiten esa cadena después de usarla. Otra ventaja es que es inherentemente seguro para concurrencia; nadie puede modificarlo en circunstancias normales.

Concatenación

La sintaxis de concatenación de cadenas se muestra a continuación, usando directamente el operador + para concatenar.

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

La operación de concatenación se completa en tiempo de ejecución por la función runtime.concatstrings. Si se trata de una concatenación de literales como la siguiente, el compilador inferirá directamente el resultado.

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

Al generar el código ensamblador, se puede ver el resultado. Parte del código es el siguiente:

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

Obviamente, el compilador lo trata como una cadena completa, cuyo valor se determina en tiempo de compilación. No será concatenada por runtime.concatstrings en tiempo de ejecución. Solo cuando se concatenan variables de cadena se completará en tiempo de ejecución. La signatura de la función es la siguiente, recibe un array de bytes y un slice de cadenas.

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

Cuando el número de variables de cadena a concatenar es menor que 5, se usarán las siguientes funciones en su lugar (suposición personal: debido a la传递 de parámetros y variables anónimas, todas están en la pila, ¿es mejor para GC que un slice creado en tiempo de ejecución?), aunque finalmente la concatenación se completa por concatstrings.

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

Veamos qué hace la función concatstrings:

go
func concatstrings(buf *tmpBuf, a []string) string {
  idx := 0
  l := 0
  count := 0
  for i, x := range a {
    n := len(x)
    // Saltar si la longitud es 0
    if n == 0 {
      continue
    }
    // Desbordamiento numérico
    if l+n < l {
      throw("string concatenation too long")
    }
    l += n
    // Contar
    count++
    idx = i
  }
  // Devolver cadena vacía si no hay cadenas
  if count == 0 {
    return ""
  }

  // Devolver directamente si solo hay una cadena
  if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
    return a[idx]
  }
  // Asignar memoria para la nueva cadena
  s, b := rawstringtmp(buf, l)
  for _, x := range a {
    // Copiar
    copy(b, x)
    // Truncar
    b = b[len(x):]
  }
  return s
}

Lo primero que hace es calcular la longitud total y la cantidad de cadenas a concatenar, luego asigna memoria según la longitud total. La función rawstringtmp devuelve una cadena s y un slice de bytes b. Aunque su longitud está determinada, no tienen contenido porque esencialmente son dos punteros a nuevas direcciones de memoria. El código de asignación de memoria es el siguiente:

go
func rawstring(size int) (s string, b []byte) {
    // Sin tipo especificado
  p := mallocgc(uintptr(size), nil, false)
    // Aunque se asignó memoria, no hay nada en ella
  return unsafe.String((*byte)(p), size), unsafe.Slice((*byte)(p), size)
}

La cadena s devuelta es para facilitar la representación, y el slice de bytes b es para facilitar la modificación de la cadena. Ambos apuntan a la misma dirección de memoria.

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

La función copy llama a runtime.slicecopy en tiempo de ejecución, cuyo trabajo es copiar directamente la memoria de src a la dirección de dst. Después de copiar todas las cadenas, el proceso de concatenación termina. Si las cadenas copiadas son muy grandes, este proceso consumirá considerable rendimiento.

Conversión

Como se mencionó anteriormente, las cadenas no se pueden modificar. Si se intenta modificar, ni siquiera se podrá compilar. Go mostrará el siguiente error:

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

Para modificar una cadena, primero se debe convertir a un slice de bytes []byte. Es muy fácil de usar:

go
bs := []byte(str)

Internamente llama a la función runtime.stringtoslicebyte, cuya lógica es muy simple:

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 longitud de la cadena es menor que la longitud del búfer, devuelve directamente el slice de bytes del búfer. Esto puede ahorrar memoria al convertir cadenas pequeñas. De lo contrario, asignará un área de memoria equivalente a la longitud de la cadena y copiará la cadena a la nueva dirección de memoria. La función rawbyteslice(len(s)) hace lo mismo que la función rawstring anterior, es decir, asignar memoria.

De manera similar, un slice de bytes se puede convertir fácilmente a una cadena en sintaxis:

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

Internamente llama a la función runtime.slicebytetostring, que también es fácil de entender:

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

Primero maneja los casos especiales donde el slice tiene longitud 0 o 1, en cuyo caso no es necesario copiar memoria. Luego, si la longitud es menor que la del búfer, usa la memoria del búfer; de lo contrario, asigna nueva memoria. Finalmente, usa la función memmove para copiar directamente la memoria. La memoria copiada no tiene relación con la memoria de origen, por lo que se puede modificar libremente.

Vale la pena señalar que ambos métodos de conversión anteriores requieren copiar memoria. Si la memoria a copiar es muy grande, el consumo de rendimiento también será grande. Cuando se actualizó a go1.20, el paquete unsafe actualizó las siguientes funciones:

go
// Pasa un puntero de tipo a la dirección de memoria y la longitud de datos, devuelve su forma de slice
func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType

// Pasa un slice, obtiene un puntero a su array subyacente
func SliceData(slice []ArbitraryType) *ArbitraryType

// Según la dirección y longitud pasadas, devuelve una cadena
func String(ptr *byte, len IntegerType) string

// Pasa una cadena, devuelve su dirección de inicio, pero los bytes devueltos no se pueden modificar
func StringData(str string) *byte

Especialmente las funciones String y StringData, que no implican copia de memoria y también pueden completar la conversión. Sin embargo, es necesario asegurarse de que los datos sean de solo lectura y no habrá modificaciones posteriores. De lo contrario, la cadena cambiará. Veamos el siguiente ejemplo:

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

Primero se obtiene la dirección del array subyacente del slice de bytes mediante SliceData, luego se obtiene su forma de cadena mediante String. Posteriormente, al modificar directamente el slice de bytes, la cadena también cambiará, lo que obviamente va en contra de la intención original de las cadenas. Veamos otro ejemplo:

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

Después de obtener la forma de slice de una cadena, si se intenta modificar el slice de bytes, se producirá un fatal directamente. Veamos qué diferencia hay al cambiar la forma de declarar la cadena:

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!

Como se puede ver en el resultado, efectivamente se modificó con éxito. La razón del fatal anterior es que la variable str almacena un literal de cadena. Los literales de cadena se almacenan en un segmento de datos de solo lectura, no en el heap o la pila, lo que从根本上 descarta la posibilidad de que los literales de cadena puedan ser modificados posteriormente. Para una variable de cadena ordinaria, esencialmente sí puede ser modificada, pero el compilador no permite esta escritura. En resumen, usar funciones unsafe para operar la conversión de cadenas no es seguro, a menos que se pueda garantizar que nunca se modificarán los datos.

Iteración

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

Para manejar el caso de caracteres multibyte, generalmente se usa el bucle for range para iterar sobre una cadena. Cuando se usa for range para iterar sobre una cadena, el compilador la expande durante la compilación a la siguiente forma de código:

go
ha := s
for hv1 := 0; hv1 < len(ha); {
    hv1t := hv1
    hv2 := rune(ha[hv1])
    // Determinar si es un carácter de un solo byte
    if hv2 < utf8.RuneSelf {
        hv1++
    } else {
        hv2, hv1 = decoderune(ha, hv1)
    }
    i, r = hv1t, hv2
  // Cuerpo del bucle
}

En el código expandido, el bucle for range se reemplaza por un bucle for clásico. En el bucle, se determina si el byte actual es un carácter de un solo byte. Si es un carácter multibyte, se llama a la función en tiempo de ejecución runtime.decoderune para obtener su codificación completa, luego se asigna a i y r. Después de procesar, se ejecuta el cuerpo del bucle definido en el código fuente.

El trabajo de construir el código intermedio es completado por la función walkRange en cmd/compile/internal/walk/range.go, que también es responsable de manejar todos los tipos que pueden ser iterados por for range. Aquí no se expande, los interesados pueden investigarlo por su cuenta.

Golang editado por www.golangdev.cn