Codificação de caracteres no .NET

Este artigo fornece uma introdução aos sistemas de codificação de caracteres (char) que são usados pelo .NET. É explicado como os tipos String, Char, Rune e StringInfo funcionam com Unicode, UTF-16 e UTF-8.

O termo caractere (char) é usado aqui no sentido geral do que um leitor percebe como um único elemento de exibição. Exemplos comuns são a letra "a", o símbolo "@" e o emoji "🐂". Às vezes, o que parece um caractere (char) é, na verdade, composto por diversos elementos de exibição independentes, como explica a seção sobre clusters de grafema.

Os tipos string e char

Uma instância da classe string representa um texto. Um string é, logicamente, uma sequência de valores de 16 bits, em que cada um é uma instância do struct char. A propriedade string.Length retorna o número de instâncias char na instância string.

A função de exemplo a seguir imprime em notação hexadecimal os valores de todas as instâncias de char em um string:

void PrintChars(string s)
{
    Console.WriteLine($"\"{s}\".Length = {s.Length}");
    for (int i = 0; i < s.Length; i++)
    {
        Console.WriteLine($"s[{i}] = '{s[i]}' ('\\u{(int)s[i]:x4}')");
    }
    Console.WriteLine();
}

Transmita o string "Hello" para esta função e você obterá a seguinte saída:

PrintChars("Hello");
"Hello".Length = 5
s[0] = 'H' ('\u0048')
s[1] = 'e' ('\u0065')
s[2] = 'l' ('\u006c')
s[3] = 'l' ('\u006c')
s[4] = 'o' ('\u006f')

Cada caractere (char) é representado por um único valor char. Esse padrão vale para a maioria dos idiomas do mundo. Por exemplo, veja a seguinte saída para dois caracteres (char) em chinês que soam como nǐ hǎo e significam Olá:

PrintChars("你好");
"你好".Length = 2
s[0] = '你' ('\u4f60')
s[1] = '好' ('\u597d')

No entanto, para alguns idiomas e para alguns símbolos e emojis, é preciso ter duas instâncias de char para representar um único caractere (char). Por exemplo, compare os caracteres (char) e as instâncias de char na palavra que significa Osage na língua Osage:

PrintChars("𐓏𐓘𐓻𐓘𐓻𐓟 𐒻𐓟");
"𐓏𐓘𐓻𐓘𐓻𐓟 𐒻𐓟".Length = 17
s[0] = '�' ('\ud801')
s[1] = '�' ('\udccf')
s[2] = '�' ('\ud801')
s[3] = '�' ('\udcd8')
s[4] = '�' ('\ud801')
s[5] = '�' ('\udcfb')
s[6] = '�' ('\ud801')
s[7] = '�' ('\udcd8')
s[8] = '�' ('\ud801')
s[9] = '�' ('\udcfb')
s[10] = '�' ('\ud801')
s[11] = '�' ('\udcdf')
s[12] = ' ' ('\u0020')
s[13] = '�' ('\ud801')
s[14] = '�' ('\udcbb')
s[15] = '�' ('\ud801')
s[16] = '�' ('\udcdf')

Cada caractere (char) no exemplo anterior, exceto o espaço, é representado por duas instâncias de char.

Um único emoji Unicode também é representado por dois chars, como visto no seguinte exemplo que mostra um emoji de boi:

"🐂".Length = 2
s[0] = '�' ('\ud83d')
s[1] = '�' ('\udc02')

Esses exemplos mostram que o valor de string.Length, que indica o número de instâncias de char, não indica necessariamente o número de caracteres (char) exibidos. Uma única instância de char por si só não representa necessariamente um caractere (char).

Os pares char que são mapeados para um único caractere (char) são chamados de pares alternativos. Para entender como eles funcionam, é necessário entender as codificações Unicode e UTF-16.

Pontos de código Unicode

O Unicode é um padrão de codificação internacional para uso em diversas plataformas com diversas linguagens e scripts.

O padrão Unicode define mais de 1,1 milhão de pontos de código. Um ponto de código é um valor inteiro que pode variar de 0 a U+10FFFF (1,114,111 decimal). Alguns pontos de código são atribuídos a letras, símbolos ou emojis. Outros são atribuídos a ações que controlam como textos ou caracteres (char) são exibidos, como avançar para uma nova linha. Muitos pontos de código ainda não foram atribuídos.

Veja os seguintes exemplos de atribuições de ponto de código, com links para gráficos (char) Unicode nos quais eles aparecem:

Decimal Hex Exemplo Descrição
10 U+000A N/D NOVA LINHA
97 U+0061 um LETRA A MINÚSCULA DO LATIM
562 U+0232 Ȳ LETRA Y MAIÚSCULA DO LATINA COM MACRO
68.675 U+10C43 𐱃 LETRA TURCA ANTIGA ORKHON AT
127.801 U+1F339 🌹 Emoji de ROSA

Os pontos de código são normalmente referenciados usando a sintaxe U+xxxx, em que xxxx é o valor inteiro codificado em hexadecimal.

Dentro do intervalo completo de pontos de código, há dois subintervalos:

  • O BMP (Plano Multilíngue Básico) no intervalo U+0000..U+FFFF. Esse intervalo de 16 bits fornece 65.536 pontos de código, o que é suficiente para cobrir a maioria dos sistemas de escrita do mundo.
  • Pontos de código suplementares no intervalo U+10000..U+10FFFF. Esse intervalo de 21 bits fornece mais de um milhão de pontos de código adicionais que podem ser usados para idiomas menos conhecidos e outros fins, como emojis.

O diagrama a seguir ilustra a relação entre o BMP e os pontos de código suplementares.

BMP e pontos de código suplementares

Unidades de código UTF-16

O UTF-16 (Formato de Transformação Unicode de 16 Bits) é um sistema de codificação de caracteres (char) que usa unidades de código de 16 bits para representar pontos de código Unicode. O .NET usa UTF-16 para codificar o texto em um string. Uma instância de char representa uma unidade de código de 16 bits.

Uma única unidade de código de 16 bits pode representar qualquer ponto de código no intervalo de 16 bits do Plano Multilíngue Básico. Porém, para um ponto de código no intervalo suplementar, são necessárias duas instâncias de char.

Pares alternativos

A tradução de dois valores de 16 bits em um único valor de 21 bits é facilitada por um intervalo especial chamado de pontos de código alternativos, que vai de U+D800 a U+DFFF (55.296 a 57.343 decimal), incluindo esse valores.

O diagrama a seguir ilustra a relação entre o BMP e os pontos de código alternativos.

BMP e pontos de código alternativos

Quando um ponto de código alternativo de nível alto (U+D800..U+DBFF) é imediatamente seguido por um ponto de código alternativo de nível baixo (U+DC00..U+DFFF), o par é interpretado como um ponto de código suplementar usando a seguinte fórmula:

code point = 0x10000 +
  ((high surrogate code point - 0xD800) * 0x0400) +
  (low surrogate code point - 0xDC00)

Veja a mesma fórmula a seguir usando a notação decimal.

code point = 65,536 +
  ((high surrogate code point - 55,296) * 1,024) +
  (low surrogate code point - 56,320)

Um ponto de código alternativo de nível alto não tem um valor numérico maior do que um ponto de código alternativo de nível baixo. O ponto de código é chamado de alternativo “de nível alto” porque é usado para calcular a ordem dos 10 bits superiores de um intervalo de pontos de código de 20 bits. O ponto de código alternativo de nível baixo é usado para calcular a ordem dos 10 bits inferiores.

Por exemplo, o ponto de código real que corresponde ao par alternativo 0xD83C e 0xDF39 é calculado da seguinte maneira:

actual = 0x10000 + ((0xD83C - 0xD800) * 0x0400) + (0xDF39 - 0xDC00)
       = 0x10000 + (          0x003C  * 0x0400) +           0x0339
       = 0x10000 +                      0xF000  +           0x0339
       = 0x1F339

Veja o mesmo cálculo a seguir usando a notação decimal.

actual =  65,536 + ((55,356 - 55,296) * 1,024) + (57,145 - 56320)
       =  65,536 + (              60  * 1,024) +             825
       =  65,536 +                     61,440  +             825
       = 127,801

O exemplo anterior demonstra que "\ud83c\udf39" é a codificação UTF-16 do ponto de código U+1F339 ROSE ('🌹') mencionado anteriormente.

Valores escalares Unicode

O termo valor escalar Unicode refere-se a todos os pontos de código diferentes dos pontos de código alternativos. Em outras palavras, um valor escalar é qualquer ponto de código atribuído a um charatuador ou pode ser atribuído a um charatuador no futuro. Aqui, "caractere" se refere a qualquer coisa que possa ser atribuída a um ponto de código, o que inclui coisas como ações que controlam como o texto ou os caracteres (char) são exibidos.

O diagrama a seguir ilustra os pontos de código de valor escalar.

Valores escalares

O tipo Rune como um valor escalar

A partir do .NET Core 3.0, o tipo System.Text.Rune representa um valor escalar Unicode. Rune não está disponível no .NET Core 2.x ou no .NET Framework 4.x.

Os construtores Rune validam se a instância resultante é um valor escalar Unicode válido. Se ela não for, eles gerarão uma exceção. O seguinte exemplo mostra que o código criou as instâncias de Rune com sucesso porque a entrada representa valores escalares válidos:

Rune a = new Rune('a');
Rune b = new Rune(0x0061);
Rune c = new Rune('\u0061');
Rune d = new Rune(0x10421);
Rune e = new Rune('\ud801', '\udc21');

O seguinte exemplo gera uma exceção porque o ponto de código está no intervalo alternativo e não faz parte de um par alternativo:

Rune f = new Rune('\ud801');

O seguinte exemplo gera uma exceção porque o ponto de código está além do intervalo suplementar:

Rune g = new Rune(0x12345678);

Exemplo de uso de Rune: troca do uso de maiúsculas e minúsculas em letras

Uma API que usa um char e que pressupõe que ele está funcionando com um ponto de código que é um valor escalar não funcionará corretamente se o char for de um par alternativo. Por exemplo, considere o seguinte método que chama Char.ToUpperInvariant em cada char em um string:

// THE FOLLOWING METHOD SHOWS INCORRECT CODE.
// DO NOT DO THIS IN A PRODUCTION APPLICATION.
static string ConvertToUpperBadExample(string input)
{
    StringBuilder builder = new StringBuilder(input.Length);
    for (int i = 0; i < input.Length; i++) /* or 'foreach' */
    {
        builder.Append(char.ToUpperInvariant(input[i]));
    }
    return builder.ToString();
}

Se o inputstring contiver a letra minúscula Deseret er (𐑉), este código não a converterá em maiúscula (𐐡). O código chama char.ToUpperInvariant separadamente em cada ponto de código alternativo, U+D801 e U+DC49. No entanto, como U+D801 não tem informações suficientes por si só para identificá-la como uma letra minúscula, char.ToUpperInvariant a ignora. E o mesmo acontece com U+DC49. O resultado é que '𐑉' minúsculo no inputstring não é convertido em '𐐡' maiúsculo.

Veja as seguintes opções para converter corretamente um string em maiúscula:

  • Chame String.ToUpperInvariant na entrada string em vez de iterar char por char. O método string.ToUpperInvariant tem acesso a ambas as partes de cada par alternativo, portanto, pode lidar com todos os pontos de código Unicode corretamente.

  • Faça a iteração nos valores escalares Unicode como instâncias de Rune em vez de como instâncias de char, conforme mostrado no exemplo a seguir. Como uma instância de Rune é um valor escalar Unicode válido, ela pode ser transmitida às APIs que esperam operar em um valor escalar. Por exemplo, chamar Rune.ToUpperInvariant fornece resultados corretos, conforme mostrado no seguinte exemplo:

    static string ConvertToUpper(string input)
    {
        StringBuilder builder = new StringBuilder(input.Length);
        foreach (Rune rune in input.EnumerateRunes())
        {
            builder.Append(Rune.ToUpperInvariant(rune));
        }
        return builder.ToString();
    }
    

Outras APIs de Rune

O tipo Rune expõe análogos de muitas das APIs de char. Por exemplo, os seguintes métodos espelham APIs estáticas no tipo char:

Para obter o valor escalar bruto de uma instância de Rune, use a propriedade Rune.Value.

Para converter uma instância de Rune novamente em uma sequência de chars, use o método Rune.ToString ou Rune.EncodeToUtf16.

Como qualquer valor escalar Unicode é representável por um único char ou por um par alternativo, qualquer instância de Rune pode ser representada no máximo por duas instâncias de char. Use Rune.Utf16SequenceLength para ver quantas instâncias de char são necessárias para representar uma instância de Rune.

Para saber mais sobre o tipo Rune de .NET, confira a Referência da API Rune.

Clusters de grafema

Como o que parece ser um único caractere (char) pode resultar de uma combinação de diversos pontos de código, um termo mais descritivo e que é geralmente usado no lugar de "caractere” (char) é cluster de grafema. O termo equivalente no .NET é elemento de texto.

Considere as instâncias de string "a", "á", "á" e "👩🏽‍🚒". Se o seu sistema operacional lidar com elas conforme especificado pelo padrão Unicode, cada uma dessas instâncias de string aparecerá como um único elemento de texto ou cluster de grafema. No entanto, as duas últimas são representadas por mais de um ponto de código de valor escalar.

  • O string "a" é representado por um valor escalar e contém uma instância de char.

    • U+0061 LATIN SMALL LETTER A
  • O string "á" é representado por um valor escalar e contém uma instância de char.

    • U+00E1 LATIN SMALL LETTER A WITH ACUTE
  • O string "á" é igual ao "á", mas é representado por dois valores escalares e contém duas instâncias de char.

    • U+0061 LATIN SMALL LETTER A
    • U+0301 COMBINING ACUTE ACCENT
  • Por fim, o string "👩🏽‍🚒" é representado por quatro valores escalares e contém sete instâncias de char.

    • U+1F469 WOMAN (intervalo suplementar, requer um par alternativo)
    • U+1F3FD EMOJI MODIFIER FITZPATRICK TYPE-4 (intervalo suplementar, requer um par alternativo)
    • U+200D ZERO WIDTH JOINER
    • U+1F692 FIRE ENGINE (intervalo suplementar, requer um par alternativo)

Em alguns dos exemplos anteriores, como o modificador de acento combinado ou o modificador de tom de pele, o ponto de código não é exibido como um elemento autônomo na tela. Em vez disso, ele serve para modificar a aparência de um elemento de texto que veio antes dele. Esses exemplos mostram que podem ser necessários diversos valores escalares para compor o que compreendemos como um único "caractere” (char) ou "cluster de grafema".

Para enumerar os clusters de grafema de um string, use a classe StringInfo, conforme mostrado no exemplo a seguir. Se você estiver familiarizado com Swift, o tipo StringInfo do .NET será conceitualmente semelhante ao tipo character do Swift.

Exemplo: contagem de instâncias de char, Rune e elemento de texto

Nas APIs do .NET, um cluster de grafema é chamado de elemento de texto. O método a seguir demonstra as diferenças entre instâncias de char, Rune e elemento de texto em um string:

static void PrintTextElementCount(string s)
{
    Console.WriteLine(s);
    Console.WriteLine($"Number of chars: {s.Length}");
    Console.WriteLine($"Number of runes: {s.EnumerateRunes().Count()}");

    TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator(s);

    int textElementCount = 0;
    while (enumerator.MoveNext())
    {
        textElementCount++;
    }

    Console.WriteLine($"Number of text elements: {textElementCount}");
}
PrintTextElementCount("a");
// Number of chars: 1
// Number of runes: 1
// Number of text elements: 1

PrintTextElementCount("á");
// Number of chars: 2
// Number of runes: 2
// Number of text elements: 1

PrintTextElementCount("👩🏽‍🚒");
// Number of chars: 7
// Number of runes: 4
// Number of text elements: 1

Se você executar esse código no .NET Framework ou no .NET Core 3.1 ou anterior, a contagem de elementos de texto do emoji mostrará 4. Isso ocorre devido a um bug na classe StringInfo que foi corrigido no .NET 5.

Exemplo: divisão de instâncias de string

Ao dividir instâncias de string, evite dividir pares alternativos e clusters de grafema. Considere o exemplo a seguir de um código incorreto, que pretende inserir quebras de linha a cada 10 caracteres (char) em um string:

// THE FOLLOWING METHOD SHOWS INCORRECT CODE.
// DO NOT DO THIS IN A PRODUCTION APPLICATION.
static string InsertNewlinesEveryTencharsBadExample(string input)
{
    StringBuilder builder = new StringBuilder();

    // First, append chunks in multiples of 10 chars
    // followed by a newline.
    int i = 0;
    for (; i < input.Length - 10; i += 10)
    {
        builder.Append(input, i, 10);
        builder.AppendLine(); // newline
    }

    // Then append any leftover data followed by
    // a final newline.
    builder.Append(input, i, input.Length - i);
    builder.AppendLine(); // newline

    return builder.ToString();
}

Como esse código enumera instâncias de char, um par alternativo que acaba se estendendo por um limite de 10-char será dividido e uma nova linha será injetada entre as partes. Essa inserção introduz corrupção de dados, pois os pontos de código alternativos são significativos somente como pares.

O potencial de corrupção de dados não é eliminado ao enumerar instâncias de Rune (valores escalares) em vez de instâncias de char. Um conjunto de instâncias de Rune pode criar um cluster de grafema que se estende por um limite de 10-char. Se o conjunto de clusters de grafema for dividido, ele não poderá ser interpretado corretamente.

Uma abordagem melhor é dividir string contando clusters de grafema, ou elementos de texto, como no seguinte exemplo:

static string InsertNewlinesEveryTenTextElements(string input)
{
    StringBuilder builder = new StringBuilder();

    // Append chunks in multiples of 10 chars

    TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator(input);

    int textElementCount = 1;
    while (enumerator.MoveNext())
    {
        builder.Append(enumerator.Current);
        if (textElementCount % 10 == 0 && textElementCount > 0)
        {
            builder.AppendLine(); // newline
        }
        textElementCount++;
    }

    // Add a final newline.
    builder.AppendLine(); // newline
    return builder.ToString();

}

Conforme observado anteriormente, antes do .NET 5, a classe StringInfo continha um bug fazendo com que alguns clusters de grafema fossem tratados incorretamente.

UTF-8 e UTF-32

As seções anteriores se concentraram em UTF-16 porque é isso o que o .NET usa para codificar as instâncias de string. Há outros sistemas de codificação para Unicode – UTF-8 e UTF-32. Essas codificações usam unidades de código de 8 bits e unidades de código de 32 bits, respectivamente.

Assim como UTF-16, UTF-8 requer diversas unidades de código para representar alguns valores escalares Unicode. UTF-32 pode representar qualquer valor escalar em uma única unidade de código de 32 bits.

Veja os seguintes exemplos que mostram como o mesmo ponto de código Unicode é representado em cada um desses três sistemas de codificação Unicode:

Scalar: U+0061 LATIN SMALL LETTER A ('a')
UTF-8 : [ 61 ]           (1x  8-bit code unit  = 8 bits total)
UTF-16: [ 0061 ]         (1x 16-bit code unit  = 16 bits total)
UTF-32: [ 00000061 ]     (1x 32-bit code unit  = 32 bits total)

Scalar: U+0429 CYRILLIC CAPITAL LETTER SHCHA ('Щ')
UTF-8 : [ D0 A9 ]        (2x  8-bit code units = 16 bits total)
UTF-16: [ 0429 ]         (1x 16-bit code unit  = 16 bits total)
UTF-32: [ 00000429 ]     (1x 32-bit code unit  = 32 bits total)

Scalar: U+A992 JAVANESE LETTER GA ('ꦒ')
UTF-8 : [ EA A6 92 ]     (3x  8-bit code units = 24 bits total)
UTF-16: [ A992 ]         (1x 16-bit code unit  = 16 bits total)
UTF-32: [ 0000A992 ]     (1x 32-bit code unit  = 32 bits total)

Scalar: U+104CC OSAGE CAPITAL LETTER TSHA ('𐓌')
UTF-8 : [ F0 90 93 8C ]  (4x  8-bit code units = 32 bits total)
UTF-16: [ D801 DCCC ]    (2x 16-bit code units = 32 bits total)
UTF-32: [ 000104CC ]     (1x 32-bit code unit  = 32 bits total)

Como observado anteriormente, uma única unidade de código UTF-16 de um par alternativo não tem sentido sozinha. Da mesma forma, uma única unidade de código UTF-8 não terá sentido por si só se estiver em uma sequência de duas, três ou quatro usada para calcular um valor escalar.

Observação

A partir do C# 11, você pode representar literais string UTF-8 usando o sufixo "u8" em um literal string. Para obter mais informações sobre literais string UTF-8, confira a stringseção "literais" do artigo sobre tipos de referência internos no Guia C#.

Endianness

No .NET, as unidades de código UTF-16 de um string são armazenadas na memória contígua como uma sequência de inteiros de 16 bits (instâncias de char). Os bits de unidades de código individuais são dispostos de acordo com a extremidade da arquitetura atual.

Em uma arquitetura little-endian, o string composto pelos pontos de código UTF-16 [ D801 DCCC ] seria disposto na memória como os bytes [ 0x01, 0xD8, 0xCC, 0xDC ]. Em uma arquitetura big-endian, o mesmo string seria colocado na memória como os bytes [ 0xD8, 0x01, 0xDC, 0xCC ].

Os sistemas de computador que se comunicam entre si devem concordar sobre a representação dos dados em movimento. A maioria dos protocolos de rede usa UTF-8 como padrão ao transmitir textos, em parte para evitar problemas que podem resultar da comunicação de um computador big-endian com um computador little-endian. O string composto pelos pontos de código UTF-8 [ F0 90 93 8C ] sempre será representado como os bytes [ 0xF0, 0x90, 0x93, 0x8C ], independentemente da extremidade.

Para usar UTF-8 na transmissão de textos, os aplicativos .NET geralmente usam códigos como o do seguinte exemplo:

string stringToWrite = GetString();
byte[] stringAsUtf8Bytes = Encoding.UTF8.GetBytes(stringToWrite);
await outputStream.WriteAsync(stringAsUtf8Bytes, 0, stringAsUtf8Bytes.Length);

No exemplo anterior, o método Encoding.UTF8.GetBytes decodifica o UTF-16 string novamente em uma série de valores escalares Unicode e, em seguida, recodifica esses valores escalares em UTF-8 e coloca a sequência resultante em uma matriz byte. O método Encoding.UTF8.GetString executa a transformação oposta, convertendo uma matriz UTF-8 byte em um UTF-16 string.

Aviso

Como o UTF-8 é comum na Internet, pode ser tentador ler os bytes brutos em movimento e tratar os dados como se fossem UTF-8. No entanto, é necessário validar que eles estão realmente bem formados. Um cliente mal-intencionado pode enviar um UTF-8 mal formado ao serviço. Se você operar com esses dados como se estivessem bem formados, poderá causar erros ou falhas de segurança em seu aplicativo. Para validar dados UTF-8, é possível usar um método como Encoding.UTF8.GetString, que executará a validação ao converter os dados de entrada em um string.

Codificação bem formada

Uma codificação Unicode bem formada é um string de unidades de código que podem ser decodificadas sem ambiguidade e sem erros em uma sequência de valores escalares Unicode. Dados bem formados podem ser transcodificados livremente entre UTF-8, UTF-16 e UTF-32.

A questão sobre se uma sequência de codificação está bem formada não está relacionada à extremidade da arquitetura de um computador. Uma sequência UTF-8 mal formada continua nesse estado em computadores big-endian e little-endian.

Veja os seguintes exemplos de codificações mal formadas:

  • Em UTF-8, a sequência [ 6C C2 61 ] é mal formada porque C2 não pode ser seguido por 61.

  • Em UTF-16, a sequência [ DC00 DD00 ] (ou string"\udc00\udd00" em C#) é mal formada porque o DC00 alternativo de nível baixo não pode ser seguido por outro DD00 alternativo de nível baixo.

  • Em UTF-32, a sequência [ 0011ABCD ] é mal formada porque 0011ABCD está fora do intervalo de valores escalares Unicode.

No .NET, as instâncias de string quase sempre contêm dados UTF-16 bem formados, mas isso não é garantido. Os exemplos a seguir mostram um código C# válido que cria dados UTF-16 mal formados em instâncias de string.

  • Um literal mal formado:

    const string s = "\ud800";
    
  • Um substring que divide um par alternativo:

    string x = "\ud83e\udd70"; // "🥰"
    string y = x.Substring(1, 1); // "\udd70" standalone low surrogate
    

As APIs como Encoding.UTF8.GetString nunca retornam instâncias de string mal formadas. Os métodos Encoding.GetString e Encoding.GetBytes detectam sequências mal formadas na entrada e realizam a substituição de caracteres (char) ao gerar a saída. Por exemplo, quando Encoding.ASCII.GetString(byte[]) nota um byte que não é ASCII na entrada (fora do intervalo U+0000..U+007F), ele insere um '?' na instância de string retornada. O Encoding.UTF8.GetString(byte[]) substitui sequências UTF-8 mal formadas por U+FFFD REPLACEMENT CHARACTER ('�') na instância de string retornada. Para saber mais, confira o Padrão Unicode, nas seções 5.22 e 3.9.

Em vez de executar a substituição de caracteres (char) quando sequências mal formadas são identificadas, também é possível configurar as classes internas Encoding para gerar uma exceção. Essa abordagem é frequentemente usada em aplicativos confidenciais em que a substituição de caracteres (char) pode não ser aceitável.

byte[] utf8Bytes = ReadFromNetwork();
UTF8Encoding encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
string asString = encoding.GetString(utf8Bytes); // will throw if 'utf8Bytes' is ill-formed

Para saber como usar as classes internas Encoding, confira Como usar classes de codificação de caracteres (char) no .NET.

Confira também