La esencia de la entrevista en idioma GO: ¿cuál es el proceso de expansión del mapa?

El propósito de utilizar una tabla hash es encontrar rápidamente la clave de destino, sin embargo, a medida que se agregan más y más claves al mapa, aumenta la probabilidad de colisiones de claves. Las 8 celdas del depósito se irán llenando gradualmente y la eficiencia de buscar, insertar y eliminar claves será cada vez menos eficiente. La situación más ideal es que un depósito solo contenga una clave para O(1)poder lograr la eficiencia, pero esto consume demasiado espacio y el costo de intercambiar espacio por tiempo es demasiado alto.

El lenguaje Go usa un depósito para cargar 8 claves. Después de ubicar un determinado depósito, debe ubicar la clave específica nuevamente, que en realidad usa tiempo por espacio.

Por supuesto, esto debe hacerse hasta cierto punto; de lo contrario, todas las claves caerán en el mismo depósito, degenerando directamente en una lista vinculada, y la eficiencia de varias operaciones caerá directamente a O (n), lo cual no es aceptable.

Por lo tanto, es necesario que exista un indicador para medir la situación descrita anteriormente, y este es el resultado 装载因子. El código fuente de Go define esto 装载因子:

loadFactor := count / (2^B)

count es la cantidad de elementos en el mapa y 2^B representa la cantidad de depósitos.

Hablemos del momento en que se activa la expansión del mapa: al insertar una nueva clave en el mapa, se realizará la detección de condiciones. Si se cumplen las dos condiciones siguientes, se activará la expansión:

  1. El factor de carga supera el umbral. El umbral definido en el código fuente es 6,5.
  2. El número de depósitos de desbordamiento es demasiado: cuando B es menor que 15, es decir, el número total de depósitos 2^B es menor que 2^15, si el número de depósitos de desbordamiento excede 2^B; cuando B >= 15 , es decir, el número total de depósitos 2^B es mayor o igual a 2^15, si el número de depósitos desbordados supera 2^15.

A través del lenguaje ensamblador, puede encontrar la función correspondiente a la operación de asignación en el código fuente mapassign, El código fuente correspondiente a la condición de expansión es el siguiente:

// src/runtime/hashmap.go/mapassign

// 触发扩容时机
if !h.growing() && (overLoadFactor(int64(h.count), h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
		hashGrow(t, h)
	}

// 装载因子超过 6.5
func overLoadFactor(count int64, B uint8) bool {
	return count >= bucketCnt && float32(count) >= loadFactor*float32((uint64(1)<<B))
}

// overflow buckets 太多
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
	if B < 16 {
		return noverflow >= uint16(1)<<B
	}
	return noverflow >= 1<<15
}

explicar:

Punto 1: Sabemos que cada depósito tiene 8 vacantes. Si no hay desbordamiento y todos los depósitos están llenos, el factor de carga calculado es 8. Por lo tanto, cuando el factor de carga supera 6,5, indica que muchos depósitos están casi llenos y la eficiencia de búsqueda y la eficiencia de inserción se vuelven bajas. La expansión es necesaria en este momento.

Punto 2: Este es un complemento al punto 1. Es decir, cuando el factor de carga es relativamente pequeño, la eficiencia de búsqueda e inserción del mapa también es muy baja en este momento, y esta situación no se puede reconocer en el punto 1. El fenómeno superficial es que el numerador para calcular el factor de carga es relativamente pequeño, es decir, el número total de elementos en el mapa es pequeño, pero el número de depósitos es grande (el número de depósitos realmente asignados es grande, incluido un gran número de cubetas de desbordamiento).

No es difícil imaginar la razón de esto: la constante inserción y eliminación de elementos. Primero se insertaron muchos elementos, lo que resultó en la creación de muchos depósitos, pero el factor de carga no alcanzó el valor crítico en el punto 1 y no se activó ninguna expansión de capacidad para aliviar esta situación. Luego, eliminar elementos reduce la cantidad total de elementos y luego inserta muchos elementos, lo que resulta en la creación de muchos depósitos de desbordamiento, pero no viola las disposiciones del punto 1. ¿Qué me pueden hacer? Hay demasiados depósitos desbordados, lo que hará que las claves se dispersen y la eficiencia de búsqueda e inserción sea terriblemente baja, por lo que se introduce el segundo punto. Es como una ciudad vacía, con muchas casas pero pocos habitantes, todos dispersos, lo que dificulta encontrar gente.

Para las condiciones 1 y 2, se producirá expansión. Sin embargo, las estrategias de expansión no son las mismas: después de todo, los escenarios para hacer frente a las dos condiciones son diferentes.

Para la condición 1, hay demasiados elementos y muy pocos depósitos. Es muy simple: agregue 1 a B y el número máximo de depósitos (2 ^ B) se convierte directamente en el doble del número original de depósitos. Entonces, hay cubos nuevos y viejos. Tenga en cuenta que en este momento, todos los elementos están en el depósito anterior y no se han migrado al nuevo depósito. Además, el nuevo depósito solo tiene un número máximo que es el doble del número máximo original (2^B) (2^B * 2).

Con respecto a la condición 2, en realidad no hay tantos elementos, pero la cantidad de depósitos desbordados es particularmente grande, lo que indica que muchos depósitos no están llenos. La solución es abrir un nuevo espacio en el depósito y mover los elementos del depósito antiguo al nuevo para que las llaves en el mismo depósito estén dispuestas más juntas. De esta manera, la llave del cubo de desbordamiento se puede mover al cubo. El resultado es ahorrar espacio, mejorar la utilización del depósito y la eficiencia de búsqueda e inserción de mapas aumentará naturalmente.

Con respecto a la solución a la condición 2, el blog de Cao Da también propuso una situación extrema: si los hash clave insertados en el mapa son todos iguales, caerán en el mismo depósito, si hay más de 8, se generará un depósito de desbordamiento. y el resultado será Esto provocará que se desborden demasiados depósitos. En realidad, mover elementos no puede resolver el problema, porque en este momento toda la tabla hash se ha degenerado en una lista vinculada y la eficiencia de la operación se ha vuelto O(n).

Echemos un vistazo a cómo se realiza la expansión. Dado que la expansión del mapa requiere la reubicación de las claves/valores originales a nuevas direcciones de memoria, si es necesario reubicar una gran cantidad de claves/valores, el rendimiento se verá muy afectado. Por lo tanto, la expansión del mapa de Go adopta un método llamado "progresivo": las claves originales no se reubicarán a la vez y solo se reubicarán 2 depósitos como máximo cada vez.

La función mencionada anteriormente hashGrow()en realidad no se "reubica", simplemente asigna depósitos nuevos y cuelga los depósitos antiguos en el campo de depósitos antiguos. La acción real de mover depósitos está en growWork()la función, y growWork()la acción de llamar a la función está en las funciones mapassign y mapdelete. Es decir, cuando se inserta, modifica o elimina una clave, se intentará reubicar los depósitos. Primero verifique si los oldbuckets han sido reubicados. Específicamente, verifique si los oldbuckets son nulos.

Primero veamos hashGrow()el trabajo realizado por la función y luego veamos cómo se realiza la migración específica de depósitos.

func hashGrow(t *maptype, h *hmap) {
	// B+1 相当于是原来 2 倍的空间
	bigger := uint8(1)

	// 对应条件 2
	if !overLoadFactor(int64(h.count), h.B) {
		// 进行等量的内存扩容,所以 B 不变
		bigger = 0
		h.flags |= sameSizeGrow
	}
	// 将老 buckets 挂到 buckets 上
	oldbuckets := h.buckets
	// 申请新的 buckets 空间
	newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger)

	flags := h.flags &^ (iterator | oldIterator)
	if h.flags&iterator != 0 {
		flags |= oldIterator
	}
	// 提交 grow 的动作
	h.B += bigger
	h.flags = flags
	h.oldbuckets = oldbuckets
	h.buckets = newbuckets
	// 搬迁进度为 0
	h.nevacuate = 0
	// overflow buckets 数为 0
	h.noverflow = 0

	// ……
}

La razón principal es que se solicitó un nuevo espacio en el depósito y se procesaron los indicadores relevantes: por ejemplo, el indicador de nevacuación se establece en 0, lo que indica que el progreso de reubicación actual es 0.

Cabe mencionar h.flagsel procesamiento de:

flags := h.flags &^ (iterator | oldIterator)
if h.flags&iterator != 0 {
	flags |= oldIterator
}

Aquí primero debemos hablar sobre el operador: &^. Esto se llama 按位置 0operador. Por ejemplo:

x = 01010011
y = 01010100
z = x &^ y = 00000011

Si el bit y es 1, entonces el bit correspondiente a z será 0; de lo contrario, el bit correspondiente a z tendrá el mismo valor que el bit correspondiente a x.

Entonces, el código anterior que opera en banderas significa: primero borre los bits correspondientes de iterador y oldIterator en h.flags a 0, y luego, si se encuentra que el bit del iterador es 1, luego transfiéralo al bit oldIterator, para que oldIterator El bit de bandera se convierte en 1. El subtexto es: los depósitos ahora tienen el nombre oldBuckets y la bandera correspondiente también debe transferirse allí.

Varias banderas son las siguientes:

// 可能有迭代器使用 buckets
iterator     = 1
// 可能有迭代器使用 oldbuckets
oldIterator  = 2
// 有协程正在向 map 中写入 key
hashWriting  = 4
// 等量扩容(对应条件 2)
sameSizeGrow = 8

Echemos un vistazo a la función growWork() que realmente realiza el trabajo de reubicación.

func growWork(t *maptype, h *hmap, bucket uintptr) {
	// 确认搬迁老的 bucket 对应正在使用的 bucket
	evacuate(t, h, bucket&h.oldbucketmask())

	// 再搬迁一个 bucket,以加快搬迁进程
	if h.growing() {
		evacuate(t, h, h.nevacuate)
	}
}

La función h.growing() es muy simple:

func (h *hmap) growing() bool {
	return h.oldbuckets != nil
}

Si oldbucketsno está vacío, significa que la reubicación no se ha completado y la reubicación debe continuar.

bucket&h.oldbucketmask()Esta línea de código, como se menciona en los comentarios del código fuente, es para confirmar que el depósito movido es el depósito que estamos usando. oldbucketmask()La función devuelve la máscara de depósito del mapa antes de la expansión.

La llamada máscara de cubo se utiliza para Y el valor hash calculado por la clave con la máscara de cubo, y el resultado es el cubo en el que debe caer la clave. Por ejemplo, B = 5, entonces los 5 bits inferiores de la máscara de depósito son 11111, y los bits restantes son 0, y el valor hash se combina con él, lo que significa que solo los 5 bits inferiores del valor hash determinan en qué depósito cae la clave. en.

A continuación, centramos todos nuestros esfuerzos en reubicar la función clave evacuar. El código fuente se publica a continuación, no se ponga nervioso, agregaré comentarios extensos y definitivamente podrá entenderlo a través de los comentarios. Explicaré el proceso de reubicación en detalle más adelante.

El código fuente es el siguiente:

func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
	// 定位老的 bucket 地址
	b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
	// 结果是 2^B,如 B = 5,结果为32
	newbit := h.noldbuckets()
	// key 的哈希函数
	alg := t.key.alg
	// 如果 b 没有被搬迁过
	if !evacuated(b) {
		var (
			// 表示bucket 移动的目标地址
			x, y   *bmap
			// 指向 x,y 中的 key/val
			xi, yi int
			// 指向 x,y 中的 key
			xk, yk unsafe.Pointer
			// 指向 x,y 中的 value
			xv, yv unsafe.Pointer
		)
		// 默认是等 size 扩容,前后 bucket 序号不变
		// 使用 x 来进行搬迁
		x = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
		xi = 0
		xk = add(unsafe.Pointer(x), dataOffset)
		xv = add(xk, bucketCnt*uintptr(t.keysize))、

		// 如果不是等 size 扩容,前后 bucket 序号有变
		// 使用 y 来进行搬迁
		if !h.sameSizeGrow() {
			// y 代表的 bucket 序号增加了 2^B
			y = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))
			yi = 0
			yk = add(unsafe.Pointer(y), dataOffset)
			yv = add(yk, bucketCnt*uintptr(t.keysize))
		}

		// 遍历所有的 bucket,包括 overflow buckets
		// b 是老的 bucket 地址
		for ; b != nil; b = b.overflow(t) {
			k := add(unsafe.Pointer(b), dataOffset)
			v := add(k, bucketCnt*uintptr(t.keysize))

			// 遍历 bucket 中的所有 cell
			for i := 0; i < bucketCnt; i, k, v = i+1, add(k, uintptr(t.keysize)), add(v, uintptr(t.valuesize)) {
				// 当前 cell 的 top hash 值
				top := b.tophash[i]
				// 如果 cell 为空,即没有 key
				if top == empty {
					// 那就标志它被"搬迁"过
					b.tophash[i] = evacuatedEmpty
					// 继续下个 cell
					continue
				}
				// 正常不会出现这种情况
				// 未被搬迁的 cell 只可能是 empty 或是
				// 正常的 top hash(大于 minTopHash)
				if top < minTopHash {
					throw("bad map state")
				}

				k2 := k
				// 如果 key 是指针,则解引用
				if t.indirectkey {
					k2 = *((*unsafe.Pointer)(k2))
				}

				// 默认使用 X,等量扩容
				useX := true
				// 如果不是等量扩容
				if !h.sameSizeGrow() {
					// 计算 hash 值,和 key 第一次写入时一样
					hash := alg.hash(k2, uintptr(h.hash0))

					// 如果有协程正在遍历 map
					if h.flags&iterator != 0 {
						// 如果出现 相同的 key 值,算出来的 hash 值不同
						if !t.reflexivekey && !alg.equal(k2, k2) {
							// 只有在 float 变量的 NaN() 情况下会出现
							if top&1 != 0 {
								// 第 B 位置 1
								hash |= newbit
							} else {
								// 第 B 位置 0
								hash &^= newbit
							}
							// 取高 8 位作为 top hash 值
							top = uint8(hash >> (sys.PtrSize*8 - 8))
							if top < minTopHash {
								top += minTopHash
							}
						}
					}

					// 取决于新哈希值的 oldB+1 位是 0 还是 1
					// 详细看后面的文章
					useX = hash&newbit == 0
				}

				// 如果 key 搬到 X 部分
				if useX {
					// 标志老的 cell 的 top hash 值,表示搬移到 X 部分
					b.tophash[i] = evacuatedX
					// 如果 xi 等于 8,说明要溢出了
					if xi == bucketCnt {
						// 新建一个 bucket
						newx := h.newoverflow(t, x)
						x = newx
						// xi 从 0 开始计数
						xi = 0
						// xk 表示 key 要移动到的位置
						xk = add(unsafe.Pointer(x), dataOffset)
						// xv 表示 value 要移动到的位置
						xv = add(xk, bucketCnt*uintptr(t.keysize))
					}
					// 设置 top hash 值
					x.tophash[xi] = top
					// key 是指针
					if t.indirectkey {
						// 将原 key(是指针)复制到新位置
						*(*unsafe.Pointer)(xk) = k2 // copy pointer
					} else {
						// 将原 key(是值)复制到新位置
						typedmemmove(t.key, xk, k) // copy value
					}
					// value 是指针,操作同 key
					if t.indirectvalue {
						*(*unsafe.Pointer)(xv) = *(*unsafe.Pointer)(v)
					} else {
						typedmemmove(t.elem, xv, v)
					}

					// 定位到下一个 cell
					xi++
					xk = add(xk, uintptr(t.keysize))
					xv = add(xv, uintptr(t.valuesize))
				} else { // key 搬到 Y 部分,操作同 X 部分
					// ……
					// 省略了这部分,操作和 X 部分相同
				}
			}
		}
		// 如果没有协程在使用老的 buckets,就把老 buckets 清除掉,帮助gc
		if h.flags&oldIterator == 0 {
			b = (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
			// 只清除bucket 的 key,value 部分,保留 top hash 部分,指示搬迁状态
			if t.bucket.kind&kindNoPointers == 0 {
				memclrHasPointers(add(unsafe.Pointer(b), dataOffset), uintptr(t.bucketsize)-dataOffset)
			} else {
				memclrNoHeapPointers(add(unsafe.Pointer(b), dataOffset), uintptr(t.bucketsize)-dataOffset)
			}
		}
	}

	// 更新搬迁进度
	// 如果此次搬迁的 bucket 等于当前进度
	if oldbucket == h.nevacuate {
		// 进度加 1
		h.nevacuate = oldbucket + 1
		// Experiments suggest that 1024 is overkill by at least an order of magnitude.
		// Put it in there as a safeguard anyway, to ensure O(1) behavior.
		// 尝试往后看 1024 个 bucket
		stop := h.nevacuate + 1024
		if stop > newbit {
			stop = newbit
		}
		// 寻找没有搬迁的 bucket
		for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {
			h.nevacuate++
		}
		
		// 现在 h.nevacuate 之前的 bucket 都被搬迁完毕
		
		// 所有的 buckets 搬迁完毕
		if h.nevacuate == newbit {
			// 清除老的 buckets
			h.oldbuckets = nil
			// 清除老的 overflow bucket
			// 回忆一下:[0] 表示当前 overflow bucket
			// [1] 表示 old overflow bucket
			if h.extra != nil {
				h.extra.overflow[1] = nil
			}
			// 清除正在扩容的标志位
			h.flags &^= sameSizeGrow
		}
	}
}

Los comentarios del código de la función de evacuación son muy claros. Es fácil comprender todo el proceso de reubicación mirando el código y los comentarios, así que tenga paciencia.

El objetivo de la reubicación es trasladar los depósitos viejos a los nuevos. De la explicación anterior, sabemos que en respuesta a la condición 1, el número de depósitos nuevos se duplica como antes, y en respuesta a la condición 2, el número de depósitos nuevos es igual al anterior.

Para la condición 2, al reubicarse de cubos antiguos a cubos nuevos, dado que el número de cubos permanece sin cambios, se pueden mover por número de serie. Por ejemplo, los cubos originalmente ubicados en el número 0 aún se colocarán en los cubos número 0 después del movimiento. a la nueva ubicación.

Para la condición 1, no es tan simple. Es necesario volver a calcular el hash de la clave para determinar en qué depósito cae. Por ejemplo, resulta que B = 5. Después de calcular el hash de la clave, solo necesita mirar sus 5 bits inferiores para determinar en qué grupo cae. Después de la expansión, B se convierte en 6, por lo que es necesario examinar un bit más. Sus 6 bits inferiores determinan en qué depósito cae la clave. Esto se llama rehash.

Insertar descripción de la imagen aquí

Por lo tanto, el número de secuencia del depósito de una clave antes y después de la migración puede ser el mismo que el original, o puede ser 2^B (valor B original) más 2^B (valor B original), dependiendo de si el sexto bit del valor hash es 0 o 1.

Aclaremos otra pregunta: si B aumenta en 1 después de la expansión, significa que el número total de depósitos se duplica y el depósito número 1 original se "divide" en dos depósitos.

Por ejemplo, original B = 2, los 3 dígitos inferiores de los valores hash de 2 claves en el depósito n.° 1 son: 010, 110 respectivamente. Dado que B = 2 originalmente, los 2 bits inferiores 10determinan que caen en el depósito n.º 2. Ahora B se convierte en 3, por lo que 010caen 110en los depósitos n.º 2 y 6 respectivamente.

Insertar descripción de la imagen aquí

Después de comprender esto, lo usaremos más adelante cuando hablemos de iteración de mapas.

Hablemos de algunos puntos clave en la función de reubicación:

La función de evacuación solo completa la reubicación de un depósito a la vez, por lo que necesita atravesar todas las celdas de este depósito y copiar las celdas con valores al nuevo lugar. El depósito también está vinculado al depósito de desbordamiento, que también debe reubicarse. Por lo tanto, habrá dos capas de bucles, la capa exterior atraviesa el cubo y el cubo de desbordamiento, y la capa interior atraviesa todas las celdas del cubo. Estos bucles están en todas partes del código fuente del mapa, por lo que es necesario comprenderlos a fondo.

El código fuente menciona las partes X e Y, lo que en realidad significa que si la capacidad se expande a 2 veces, el número de cubos será 2 veces el número original. La primera mitad de los cubos se llama partes X y la segunda mitad de los cubos se llaman piezas Y. La llave en un cubo puede dividirse y caer en dos cubos, uno en la parte X y otro en la parte Y. Por lo tanto, antes de reubicar una celda, necesita saber a qué parte pertenece la clave de la celda. Es muy sencillo, vuelve a calcular el hash de la clave en la celda y "mira" hacia adelante un bit más para decidir en qué parte cae, esto ya se ha explicado en detalle antes.

Hay un caso especial: hay una clave y cada vez que se calcula el hash sobre ella, el resultado es diferente. Esta clave es math.NaN()el resultado de, lo que significa not a numberque el tipo es float64. Cuando se usa como clave de un mapa, encontrará un problema cuando se reubique: ¡su valor hash calculado nuevamente es diferente del valor hash calculado cuando se insertó originalmente en el mapa!

Quizás haya pensado que una consecuencia de esto es que esta clave nunca se obtendrá mediante la operación Obtener. Cuando uso m[math.NaN()]la declaración, no puedo encontrar el resultado. Esta clave solo tendrá la posibilidad de aparecer al atravesar todo el mapa. Por lo tanto, se puede insertar cualquier número de math.NaN()claves en un mapa.

Cuando la reubicación encuentra math.NaN()la clave, solo se usa el bit más bajo de tophash para determinar si se asigna a la parte X o a la parte Y (si la expansión es 2 veces el número original de depósitos). Si el bit más bajo de tophash es 0, se asigna a la parte X; si es 1, se asigna a la parte Y.

Esto se obtiene operando el valor tophash y el valor hash recién calculado:

if top&1 != 0 {
    // top hash 最低位为 1
    // 新算出来的 hash 值的 B 位置 1
	hash |= newbit
} else {
    // 新算出来的 hash 值的 B 位置 0
	hash &^= newbit
}

// hash 值的 B 位为 0,则搬迁到 x part
// 当 B = 5时,newbit = 32,二进制低 6 位为 10 0000
useX = hash&newbit == 0

De hecho, puedo mover dicha llave a cualquier depósito, pero por supuesto todavía necesito moverla a los dos depósitos en la imagen de fisión de arriba. Pero hay beneficios al hacer esto. Lo explicaré en detalle más adelante cuando hablemos de iteración de mapas. Por ahora, solo sepa cómo se asigna.

Después de determinar el depósito objetivo que se va a reubicar, la operación de reubicación es más fácil de realizar. Copie el valor de clave/valor de origen en la ubicación correspondiente del destino.

Establezca el tophash de la clave en los depósitos originales en evacuatedXo evacuatedY, lo que indica que se ha movido a la parte x o y del nuevo mapa. El tophash del nuevo mapa normalmente toma los 8 bits superiores del valor hash de la clave.

Echemos un vistazo macro a los cambios antes y después de la expansión en la figura.

Antes de la expansión, B = 2, hay 4 depósitos en total y los bits bajos representan los bits bajos del valor hash. Supongamos que no prestamos atención a otros grupos y nos centramos en el grupo número 2. Y suponiendo que haya demasiado desbordamiento, se desencadena una expansión igual (correspondiente a la condición 2 anterior).

Insertar descripción de la imagen aquí

Una vez completada la expansión, el depósito de desbordamiento desaparece y las claves se concentran en un depósito, que es más compacto y mejora la eficiencia de la búsqueda.

Insertar descripción de la imagen aquí

Supongamos que se activa una expansión doble. Una vez completada la expansión, las claves de los depósitos antiguos se dividen en dos depósitos nuevos. Uno en la parte x y otro en la parte y. La base son los bits bajos del hash. En el nuevo mapa, 0-3se llama parte x 4-7y parte y.

Insertar descripción de la imagen aquí

Tenga en cuenta que las dos figuras anteriores ignoran la reubicación de otros depósitos y representan la situación después de que se hayan reubicado todos los depósitos. De hecho, sabemos que la reubicación es un proceso "gradual" y no se completará de una vez. Por lo tanto, durante el proceso de reubicación, el puntero de oldbuckets seguirá apuntando al antiguo []bmap original, y el valor de tophash de la clave que se ha reubicado será un valor de estado, que indica el destino de reubicación de la clave.

Supongo que te gusta

Origin blog.csdn.net/zy_dreamer/article/details/132799777
Recomendado
Clasificación