Strings
O que string é
Seção intitulada “O que string é”A grafia string/string[u16] engana de propósito, então vale dizer exatamente o que string é no sistema de tipos, porque não é nenhuma das três coisas que parece:
- Não é primitivo escalar (como
int/u8): escalares cabem num registrador e são atômicos;stringé agregado, com comprimento, morando na memória sob@mm, com estrutura interna. - Não é alias de
[]byte: tem um invariante que[]bytenão tem (ser UTF-8 válido) e acesso por unidade (.chars/.codepoints) que[]bytenão tem. Por isso converter entre os dois é uma operação (cópia safe ou reinterpretação unsafe), não um cast grátis. - Não é generic como
List[T], e aqui a grafia mais mente: não existe um tipostring[u16].stringé sempre UTF-8, é o único tipo string que existe. Os “outros encodings” não são instâncias de um generic; são só[]byte(bytes naquela codificação). O[u16]não parametriza o tipo, e sim os métodos de conversão (to/from).
Afirmativamente: string é um tipo composto embutido (da família de arrays e slices, embutidos, não definidos por você), com um invariante (UTF-8 válido) e acesso ciente de unidade. Ele armazena bytes UTF-8 e apresenta três visões: .bytes (para byte), .codepoints (para codepoint) e .chars (para grapheme). Não é []byte (tem o invariante), nem []char (não guarda graphemes soltos, e sim bytes que decodifica sob demanda). É sua própria coisa embutida.
Disso decorre tudo. Se você tem uma string, você tem texto UTF-8 válido, ponto; bytes arbitrários ou possivelmente-inválidos são []byte. A validação mora na fronteira (a conversão, onde o erro é um Result), e o miolo do programa nunca pergunta “será que isto é UTF-8?”. E como string[u16] não é um tipo, string puro é o único que circula em toda assinatura (fn log(msg: string)); o [E] aparece só nos métodos de conversão de borda, nunca numa assinatura interna. A “Leitura-1” não é convenção, é estrutura (não há string[E] pra circular porque ele não existe). É o que Go, Rust e Swift fazem: um string só, UTF-8, e o resto é bytes mais conversão.
Os tipos elementares e char
Seção intitulada “Os tipos elementares e char”As três visões correspondem a três tipos embutidos, os átomos do texto:
byte(u8): a unidade de armazenamento. UTF-8 usa de 1 a 4 bytes por codepoint.codepoint(u32): o escalar Unicode.grapheme: o caractere visual (o que o humano vê). Pode ser vários codepoints (👨👩👧 é um grapheme de sete), então é tamanho-variável, pois carrega seus bytes e não cabe num registrador comobyte/codepoint.
E char é um apelido para grapheme (o nome familiar da unidade comum, já que o que as pessoas querem na maioria das vezes é o caractere visual), não um generic char[E]. Quer um codepoint? Escreve codepoint; não há char[codepoint]. Vale a mesma disciplina da Leitura-1: char/grapheme é o que circula no miolo, e codepoint/byte crus aparecem quando você desce ao escalar (parsing, interop). A simetria fecha o sistema: string está para char como []byte está para byte.
Literais de caractere
Seção intitulada “Literais de caractere”Um literal 'w' é um caractere comptime sem tipo fixo: assim como 5 é um inteiro comptime que vira u8/i64/int conforme o contexto, 'w' resolve para byte/codepoint/grapheme pelo contexto:
k: codepoint = 'w' // contexto define → codepoint (U+0077)match tecla { 'w' => move(); _ => {} } // 'w' é codepoint; '_' = default (codepoint é domínio aberto)b: byte = 'w' // → byte (0x77; válido, 'w' cabe em 1 byte)Onde o contexto não desambigua (x := 'w' solto), você qualifica a unidade ('w'.codepoint, 'w'.byte, 'w'.grapheme), com checagem de validade ('é'.byte é erro: ‘é’ são 2 bytes; '👨👩👧'.codepoint é erro: são vários). É a mesma disciplina do acesso a string (unidade explícita quando ambígua), aplicada ao literal. char é apelido de grapheme, então 'w'.char equivale a 'w'.grapheme.
Acesso: a unidade é sempre explícita
Seção intitulada “Acesso: a unidade é sempre explícita”Não há s[i] nem s[2..6]/s[2:6] puro: são erro de compilação. O motivo é o que bane s[i] em toda linguagem que tenta: s[2..6] é “sobre O QUE?”, byte, codepoint ou grapheme? A ambiguidade não se resolve por um default (byte é o natural pra quem implementa e o mais traiçoeiro pra quem usa: s[2..6] esperando 4 letras e recebendo 4 bytes corta um emoji no meio). A unidade é sempre qualificada.
Cada acesso tem método e shorthand, pareados para singular e range, sem nenhum caso órfão:
| Shorthand | Método | O quê |
|---|---|---|
s.bytes[2] |
s.byte_at(2) |
um, por byte |
s.bytes[2..6] |
s.bytes_view(2, 6) |
view (range), por byte |
s.bytes[2:6] |
s.bytes_copy(2, 6) |
cópia (range), por byte |
s.chars[2] |
s.char_at(2) |
um grapheme |
s.chars[2..6] |
s.chars_view(2, 6) |
view (range), por grapheme |
s.chars[2:6] |
s.chars_copy(2, 6) |
cópia (range), por grapheme |
s.codepoints[2] |
s.codepoint_at(2) |
um codepoint |
s.codepoints[2..6] |
s.codepoints_view(2, 6) |
view (range) |
s.codepoints[2:6] |
s.codepoints_copy(2, 6) |
cópia (range) |
Singular usa a unidade no singular (byte_at); range usa o plural (bytes_view), espelhando o shorthand. O _view/_copy carrega o custo no nome (O(1) vs O(n)), igual a ../: carregando no shorthand. Quem vem de Go ou Python tenta s[2..6] e toma erro de compile, o mesmo atrito-que-ensina do var nunca-mutado, e ele salva do bug real (cortar grapheme por acidente).
Views e cópias: uma primitiva, custo explícito
Seção intitulada “Views e cópias: uma primitiva, custo explícito”Recortar uma sequência tem dois custos, e a linguagem os torna duas sintaxes explícitas, em vez de esconder um:
nums[2..6] // VIEW: ptr+len pra dentro de nums; O(1); mantém nums vivo (let por default)nums[2:6] // COPY: fatia nova, independente; O(n); aloca.. é view, : é cópia, e não é arbitrário: .. já é o operador de range (loop i in 0..8), e um range é uma descrição leve de índices, que é o que uma view é. Símbolo familiar e leve para a operação leve; : (novo nesse contexto) para a que aloca. É a mesma primitiva para array, slice e string. Array e slice não precisam de qualificador (só há “elemento”, então arr[2..6] é inequívoco); string é o único tipo onde a unidade é ambígua e o único onde o qualificador (.bytes/.chars/.codepoints) é obrigatório, pela regra “exija a unidade quando ela for ambígua”. A view mantém a original viva enquanto existe, mas isso é a mesma regra de escopo do @mm (“não libere o que ainda tem referência viva”), nenhum mecanismo novo.
Conversões de borda
Seção intitulada “Conversões de borda”Os bytes UTF-8 saem da própria visão .bytes: já são UTF-8, é só view ou cópia, sem conversão. Só outros encodings passam por to[E]/from[E], e sempre explicitamente (transcodificar UTF-8UTF-16 aloca e percorre a string toda; esconder isso atrás de uma passagem de argumento contradiz a explicitude da linguagem):
s.bytes // UTF-8: view dos bytes (sem conversão)s.bytes_copy(0, n) // UTF-8: cópia dos bytess.to[u16]() -> []byte // outro encoding: transcodifica (aloca)f(s.to[u32]()) // passar outro encoding é explícito, nunca f(s)Pra construir uma string a partir de bytes, há a forma safe (valida, pode falhar) e a forma unsafe (reinterpreta sem cópia, abatida com assume):
string.from[u8](bytes) -> Result[string, error] // bytes UTF-8 → string (safe: valida)string.from[u16](bytes) -> Result[string, error] // outro encoding → string (transcodifica+valida)bytes.as_string_unchecked() // bytes UTF-8 → string (unsafe, sem cópia): // abate com assume "estes bytes são UTF-8 válido"; o _unchecked grita o perigoA forma unsafe reusa o unsafe/assume da seção 5: os bytes podem não ser UTF-8, ou seja, caso-B, abatida com assume "razão". O conjunto de encodings de borda: ASCII, Latin-1, UTF-8, UTF-16, UTF-32, raw bytes.
Os bytes de UTF-16/UTF-32 são little-endian, sem BOM: essa é a ordem canônica do Makoto (a nativa de x86/ARM), então to[u16]/from[u16] sempre falam little-endian e ficam limpos-de-spec. Falar com um sistema que quer a outra ordem de bytes ou um byte-order mark é trabalho do pacote unicode (opt-in, use unicode): unicode.swap16(b)/unicode.swap32(b) invertem a ordem de bytes (little para big e de volta, a mesma chamada nos dois sentidos), unicode.with_bom16(b, .little)/unicode.with_bom32(b, .big) prefixam um BOM para uma ordem, e unicode.strip_bom16(b)/unicode.strip_bom32(b) removem um BOM à frente e devolvem bytes little-endian canônicos, prontos para alimentar direto o from[u16]. A ordem é Endianness (.little/.big). O core carrega uma ordem; o zoológico de byte-order vive no pacote opt-in, então um programa que só fica dentro do Makoto nunca o encontra.
Comparação: operadores de byte, semântica por método
Seção intitulada “Comparação: operadores de byte, semântica por método”String tem os operadores de comparação (==, !=, <, >, <=, >=), e todos operam byte-a-byte: ==/!= é igualdade de bytes (o caso comum: protocolos, paths, identificadores), e </> são ordem lexicográfica de bytes (o que Go, Zig e Rust fazem, e o que um sort espera). Isso é o que separa comparação de indexação: a unidade de s[i] é ambígua (byte? codepoint? grapheme?), mas igualdade e ordem de bytes não são: são bem-definidas e são o que quase todo código espera. O que não existe é comparação semântica por operador; normalização Unicode e collation localizada são caso raro e viram método explícito:
a == b // igualdade de bytes (o comum, rápido)a < b // ordem lexicográfica de bytes, alimenta um sort diretoa.equals(b, .semantic) // igualdade com normalização Unicode ("é" composto == "é" pré-composto)locale.Collator.new(.sv).order(a, b) // ordem alfabética localizada (collation), subpacote 'locale'A escolha byte-vs-semântico é um método (.equals(_, .semantic), o Collator), não um segundo operador, o que mata o === antes de nascer (o === do JS só existe porque o JS gastou == em coerção; aqui == já é igualdade de bytes inambígua, sem um segundo sentido a desfazer). Comparação alfabética e localizada (collation) vive no subpacote locale, opt-in puro: quem nunca compara texto acentuado por ordem alfabética nunca importa locale, e o peso das tabelas Unicode fica fora do programa que não precisa. O s[i] continua banido (a unidade é ambígua); operador de comparação é liberado porque byte-equality e byte-order não têm essa ambiguidade.
Collation precisa de uma locale (ordenar em sueco não é ordenar em alemão ou inglês), então é um Collator construído para uma e então reusado: locale.Collator.new(.sv) a partir do enum de locales comuns Locale (.pt, .en_us, .sv, …), ou locale.Collator.from_tag("pt-BR") para qualquer tag BCP 47 fora do enum (um tag que não parseia é um Err). O collator responde order(a, b) -> Ordering e equals(a, b) -> bool. Isso torna a locale explícita em vez de um global escondido: a locale é o collator que você nomeia, não estado ambiente que a comparação lê silenciosamente.
O .semantic ali é um literal de enum ponto-líder: nomeia uma variante (semantic) cujo enum (um CompareMode, com .byte como padrão) o compilador infere do tipo esperado, a variante-inferida estilo Zig. É geral a qualquer enum, não é especial do modo de comparação: onde o tipo esperado é um enum conhecido, .Variante é a variante daquele enum (paint(.Red), let c: Color = .Blue, return .Idle), então você não repete o nome do enum que a assinatura já fixou. Onde não há enum ao alcance (um binding solto let x := .Red, um error aberto que não declara membros), ele não infere e você o nomeia por extenso (Color.Red, error.NotFound, CompareMode.semantic). Um ponto-líder não-inferível é erro de compilação, nunca um chute silencioso.
Mutação e crescimento
Seção intitulada “Mutação e crescimento”Mutar uma string é a regra de mutabilidade de sempre (seção 6): var muta, :=/let não. Não há “string mutável” como tipo, e sim um binding var:
var s := "abc"s.append("d") // muta in-place; segue as unidades (append por char/byte/codepoint)Crescer (append num loop) realoca por doubling: ao atingir a capacidade, o buffer dobra, amortizando o custo. E o compilador otimiza o caso de tamanho conhecido: se prova quanto a string vai crescer, pré-aloca o tamanho final e pula as realocações. Não há StringBuilder separado: um tipo só (var string com doubling) entrega o que o builder entregaria, sem o segundo tipo que todo mundo teria que aprender (teste do opt-in).
Literais e comptime
Seção intitulada “Literais e comptime”Todo literal "abc" é comptime-na-fonte (conhecido em compilação), mas o que ele vira depende do binding, e isso segue a regra de todos os tipos (a seção 6 separa “imutável” de “comptime”):
x := "abc" // string RUNTIME imutável (valor normal sob @mm)var x := "abc" // string runtime mutávelconst x := "abc" // comptime string: embarcada no binário, imutávelÉ o const que faz a string ser comptime (no binário), não o :=, assim como 5 vira comptime int num const e int runtime num :=. Strings não são exceção: o literal é comptime na origem, e o binding decide se permanece comptime ou é materializado em runtime.
Interpolação
Seção intitulada “Interpolação”Interpolação é nomeada, com {{ }}:
greeting := "Olá {{nome}}, você tem {{idade}} anos"Dentro de {{ }} vale qualquer expressão que produza um valor: nome, field access, function call, aritmética:
"{{user.name.trim()}} fez {{count}} pedidos, total {{total.fixed(2)}}"Uma string literal dentro de um buraco não precisa de escape: o delimitador {{ }} é distinto da aspa ", então uma "..." aninhada lê limpo, "cmp: {{ "a".cmp("b") }}". Isso segue o padrão moderno de interpolação (Swift, Kotlin, Ruby, C#, e Python 3.12 via PEP 701 permitem). A forma escapada continua valendo pra quem preferir (\"a\"), e uma string aninhada pode até conter }}. As aspas ficam simples: " só pra string, ' só pra char/rune/grapheme, e não há backticks.
O valor vira texto via a interface Display (abaixo): {{x}} é açúcar para x.to_string(). Interpolar um tipo sem Display é erro de compilação, sem nada de <Object@0x7f...> vazado; você implementa Display e aí o tipo pode ser interpolado. Com o comptime, {{nome}} é checado em compilação (o nome existe? o tipo formata?), virando erro de compile em vez de surpresa em runtime.
Formatação é por método, não por mini-linguagem: como {{ }} já aceita qualquer expressão, casas decimais, padding e hex são chamadas ({{pi.fixed(2)}}, {{x.pad(8)}}, {{n.hex()}}), em vez de uma segunda gramática ({:.2}) que todo mundo teria que aprender. Zero gramática nova, descobrível por autocomplete. Um {{ literal escapa com barra (\{{), reusando o mecanismo de escape que a string já tem (\n, \t, \r, \", \\, \0). Um ponto de código Unicode escreve-se direto como fonte UTF-8 ("é"), ou, quando a fonte precisa ficar ASCII ou nomear um ponto não-imprimível, com \u{HEX} ("caf\u{e9}" é café, '\u{1F600}' é 😀); o valor tem que ser um escalar Unicode (0…10FFFF, sem surrogates) ou é erro de compilação. Um escape genuinamente desconhecido perde a barra (\q é q): o lexer é leniente, então só os escapes significativos acima são especiais.
A interface Display
Seção intitulada “A interface Display”Display é a interface de “este tipo sabe se representar como texto”, com um método, to_string():
decl Display { fn to_string() -> string}É a mesma interface usada em qualquer função genérica sobre Display e na interpolação. Tipos primitivos (int, bool, float…) a implementam de fábrica; pros seus tipos, você implementa to_string() e ganha tanto a interpolação quanto qualquer função genérica sobre Display.
FFI e CString
Seção intitulada “FFI e CString”C usa strings null-terminated (char*); a string é comprimento-prefixada. A ponte é um método, .to_cstring(), não um tipo novo: uma CString é só “estes bytes mais um \0”, e o problema dela (quem libera? o C ou você?) é ownership, que o @mm/@transfer já governa; um tipo CString reabriria essa pergunta resolvida. s.to_cstring() devolve bytes null-terminated sob o @mm corrente, e o @transfer passa a posse pro C quando for o caso. O detalhe fica deferido com o FFI (seção 17); aqui só a direção.