C#:recorta una cadena UTF-8 al número de bytes especificado

C#:recorta una cadena UTF-8 al número de bytes especificado

Esta es la forma más sencilla de recortar eficientemente una cadena UTF-8 al número especificado de bytes:

public static string TrimToByteLength(this string input, int byteLength)
{
	if (string.IsNullOrEmpty(input))
		return input;
	
	var currentBytes = Encoding.UTF8.GetByteCount(input);
	if (currentBytes <= byteLength)
		return input;

	//Are we dealing with all 1-byte chars? Use substring(). This cuts the time in half.
	if (currentBytes == input.Length)
		return input.Substring(0, byteLength);

	var bytesArray = Encoding.UTF8.GetBytes(input);
	Array.Resize(ref bytesArray, byteLength);
	var wordTrimmed = Encoding.UTF8.GetString(bytesArray, 0, byteLength);

	//If a multi-byte sequence was cut apart at the end, the decoder will put a replacement character '�'
	//so trim off the potential trailing '�'
	return wordTrimmed.TrimEnd('�');
}
Code language: C# (cs)

Una cadena UTF-8 puede tener una combinación de caracteres de 1 a 4 bytes. Cuando solo toma parte de la matriz de bytes, puede terminar cortando los caracteres de varios bytes por la mitad, que luego se reemplazan con el carácter de reemplazo ('�') cuando decodifica. Es por eso que está recortando el carácter de reemplazo final.

Hay otros enfoques, como hacer bucles y verificar secuencias de varios bytes no válidas, pero eso conduce a un código que es más difícil de entender y no es más eficiente (según los puntos de referencia con 1 millón de cadenas de caracteres). Además, una de las mejores optimizaciones que puede hacer es usar string.Substring() si solo está tratando con caracteres de 1 byte. Eso conduce a una aceleración de 2x.

En este artículo, entraré en más detalles sobre cómo lidiar con los caracteres de varios bytes que se reducen a la mitad. Al final, mostraré todas las pruebas unitarias utilizadas para demostrar que el método TrimToByteLength() funciona.

Lidiar con un carácter de varios bytes que se redujo a la mitad

Si solo tiene que lidiar con caracteres de 1 byte, recortar la matriz de bytes no sería un problema. De hecho, si ese fuera el caso, podría usar string.Substring() en lugar de codificar/decodificar.

Pero los caracteres codificados en UTF-8 pueden tener entre 1 y 4 bytes. Dado que está recortando en función de la longitud del byte, puede terminar cortando parte de un carácter de varios bytes por la mitad.

Por ejemplo, supongamos que tiene la siguiente cadena con un carácter japonés "か". En UTF-8, este es un carácter multibyte con los siguientes tres bytes:

11100011 10000001 10001011Code language: plaintext (plaintext)

Ahora digamos que está recortando esto a solo 2 bytes. Esto dejaría los dos primeros bytes:

11100011 10000001

Esta es una secuencia no válida y, de forma predeterminada, el decodificador la reemplazaría con el carácter de reemplazo '�'.

Cualquier código que intente recortar una cadena a una longitud de bytes específica tiene que lidiar con este problema. Puede intentar detectar la secuencia de varios bytes inválida usted mismo invirtiendo la matriz de bytes y examinando los bytes, o puede dejar que el decodificador haga el trabajo por usted y simplemente elimine el carácter de reemplazo al final. El código que se muestra en este artículo tiene el último enfoque, porque es mucho más simple no reinventar la rueda.

¿Cómo se detecta la secuencia de varios bytes no válida?

UTF-8 fue diseñado para poder determinar a qué carácter pertenece un byte usando el siguiente esquema:

El primer byte comienza con segundo byte empieza con 3er byte empieza con 4.º byte empieza con
Carácter de 1 byte 0
caracter de 2 bytes 110 10 10
caracter de 3 bytes 1110 10 10
caracter de 4 bytes 11110 10 10 10

El primer byte de la secuencia indica qué tipo de secuencia es, lo que indica cuántos bytes de continuación buscar. Los bytes de continuación comienzan con 10 .

Volvamos a la matriz de bytes con el carácter japonés "か":

11100011 10000001 10001011

Cuando esto se recorta a 2 bytes:

11100011 10000001

Cuando el decodificador pasa por esto, ve que el primer byte de la secuencia comienza con 111, lo que significa que se trata de una secuencia de 3 bytes. Espera que los próximos dos bytes sean bytes de continuación (bytes que comienzan con 10 ), pero solo ve el byte de continuación (10 000001). Por lo tanto, esta es una secuencia de bytes no válida y se reemplaza con el carácter de reemplazo '�'.

Más ejemplos de caracteres y sus secuencias de bytes UTF-8

Aquí hay más ejemplos de caracteres y sus secuencias de bytes.

Personaje Unicode Secuencia de bytes
a U+0061 0 1100001
Ć U+0106 11 000100 10 000110
ꦀ (carácter javanés) U+A980 111 01010 10 100110 10 000000
𒀃 (carácter cuneiforme sumerio) U+12003 1111 0000 10 010010 10 000000 10 000011

Observe el patrón en las secuencias de bytes. Los primeros 4 bits del primer byte le indican el tipo de secuencia que desea, seguidos de los bytes de continuación (que comienzan con 10 ).

Pruebas unitarias

El método TrimToByteLength() se probó utilizando las siguientes pruebas unitarias parametrizadas. Esto ejercita todos los escenarios, incluida la verificación de lo que sucede cuando las secuencias de varios bytes se separan.

[TestClass()]
public class TrimToByteLengthTests
{
	[DataRow(null)]
	[DataRow("")]
	[TestMethod()]
	public void WhenEmptyOrNull_ReturnsAsIs(string input)
	{
		//act
		var actual = input.TrimToByteLength(10);

		//assert
		Assert.AreEqual(input, actual);
	}
	[DataRow("a")] //1 byte
	[DataRow("Ć")] //2 bytes
	[DataRow("ꦀ")] //3 bytes - Javanese
	[DataRow("𒀃")] //4 bytes - Sumerian cuneiform
	[DataRow("a𒀃")] //5 bytes
	[TestMethod()]
	public void WhenSufficientLengthAlready_ReturnsAsIs(string input)
	{
		//act
		var actual = input.TrimToByteLength(byteLength: 5);

		//assert
		Assert.AreEqual(input, actual);
	}
	[DataRow("abc", 1, "a")] //3 bytes, want 1
	[DataRow("abĆ", 2, "ab")] //4 bytes, want 2
	[DataRow("aꦀ", 1, "a")] //4 bytes, want 1
	[DataRow("a𒀃c", 5, "a𒀃")] //6 bytes, want 5
	[DataRow("aĆ𒀃", 3, "aĆ")] //7 bytes, want 3
	[TestMethod()]
	public void WhenStringHasTooManyBytes_ReturnsTrimmedString(string input, int byteLength, string expectedTrimmedString)
	{
		//act
		var actual = input.TrimToByteLength(byteLength);

		//assert
		Assert.AreEqual(expectedTrimmedString, actual);
	}
	[DataRow("Ć", 1, "")] //2 byte char, cut in half
	[DataRow("ꦀ", 2, "")] //3 byte char, cut at 3rd byte
	[DataRow("ꦀ", 1, "")] //3 byte char, cut at 2nd byte
	[DataRow("𒀃", 3, "")] //4 byte char, cut at 4th byte
	[DataRow("𒀃", 2, "")] //4 byte char, cut at 3rd byte
	[DataRow("𒀃", 1, "")] //4 byte char, cut at 2nd byte
	[DataRow("a𒀃", 2, "a")] //1 byte + 4 byte char. Multi-byte cut in half
	[TestMethod()]
	public void WhenMultiByteCharSequenceIsCutInHalf_ItAndReplacementCharAreTrimmedOut(string input, int byteLength, string expectedTrimmedString)
	{
		//act
		var actual = input.TrimToByteLength(byteLength);

		//assert
		Assert.AreEqual(expectedTrimmedString, actual);
	}
}
Code language: C# (cs)