Po co w Javowym MUTF-8 znak NUL jest kodowany jako C0 80?

0

Cytując Wikipedię:

Modified UTF-8 (MUTF-8) originated in the Java programming language. In Modified UTF-8, the null character (U+0000) uses the two-byte overlong encoding 11000000 10000000 (hexadecimal C0 80), instead of 00000000 (hexadecimal 00).[65] Modified UTF-8 strings never contain any actual null bytes but can contain all Unicode code points including U+0000,[66] which allows such strings (with a null byte appended) to be processed by traditional null-terminated string functions.

Jestem zdziwiony.

Poprawcie mnie, jeśli się mylę, ale jak rozumiem, taka jest historia znaku NUL:

  1. W dawnych czasach (C itp) wymyślono, że wygodnie będzie założyć, że każdy ciąg znaków ma się kończyć specjalnym znakiem oznaczającym koniec tegoż ciągu znaków. Na taki specjalny znak wybrano 0x00. OK, w porządku.
  2. Aha, a więc powstał nowy znak do alfabetu, nazywamy go znakiem NUL.
  3. A skoro mamy nowy znak w alfabecie, to musimy umieć przedstawić go w napisie! Ups, ale nie możemy tak zrobić, bo z definicji ten znak kończy napis.
  4. Wspaniałe rozwiązanie: nasz stary, dobry NUL przenosi się na pozycję 0xC080, natomiast na pozycję 0x00 wstawiamy nowego nulla, który przejmuje funkcję starego nulla, czyli kończy ciąg znaków.

Aż się prosi, żeby dopisać ciąg dalszy tej historii:

  1. Aha, a więc powstał nowy znak do alfabetu, nazwijmy go znakiem MNUL (Modified NUL).
  2. A skoro tak, to powinniśmy być w stanie przedstawić go w napisie, z analogicznych powodów jak te, dla których chcemy starego NULa umieć przedstawić w napisie. Jednak nie możemy tego uczynić, bo postanowiliśmy, że MNUL zawsze kończy napis.
  3. Wspaniałem rozwiązanie: nasz nowy stary MNUL przenosi się na pozycję [tu wstaw dowolną dostępną pozycję], zaś na pozycję 0x00 wstawiamy jeszcze jednego nulla, który przejmuje funkcję obu starych nulli, czyli kończy ciąg znaków. Tak utworzone kodowanie znaków nazywamy 2MUTF-8 (Doubly Modified UTF-8).
  4. Aha, a więc powstał nowy znak do alfabetu, nazwijmy go znakiem 2MNUL (Doubly Modified NUL).
  5. A skoro mamy nowy znak w alfabecie.... i tak dalej, i tak dalej.

Przecież tak można w nieskończoność, to nie ma sensu.

Przyznaję się do słabości. Krytykuję, a nie mam lepszego pomysłu. Są przecież standardy, które nie kończą każdego napisu nulem w żadnej z jego inkarnacji, np. C# nie kończy stringów NULem i stringi mogą NULe przechowywać. Nie wiem, ale ślepo strzelam, że w takim razie każdy string musi przechowywać swoją długość? Ale w takim wypadku zachodzi konieczność przenoszenia napisów między różnymi standardami, na przykład przydałoby się być w stanie przechować napis C#-owy w napisie zakończonym nulem i to tak, by móc później odtworzyć oryginalny napis z powrotem do C#.

Unicode wprowadził znak U+2400 (␀). Z początku myślałem, że to jest prawidłowe rozwiązanie problemu: przerzucamy U+0000 na U+2400 i z powrotem. Ale przecież tak nie może być, bo to wprowadza niejednoznaczność. Co na przykład zrobić z takim napisem: W języku C każdy string jest zakończony znakiem ␀?

3

Aż się prosi, żeby dopisać ciąg dalszy tej historii:

No chyba jednak nie zrozumiałeś tego co cytujesz. Trik polega na tym, ze stary NULL jest cały czas taki sam, jest nim 0, żeby funkcje rodem z C mogły to ogarniać. Ale gdybyś chciał w stringu mieć nullbyte, to używasz tego kodowanego w specjalny sposób.

Sugerowałbym też nie mylić semantyki z syntaktyką. Czym innym są "dane" a czym innym jest "sposób zapisu danych". To że NULL w UTF-8 jest kodowany samymi zerami wynika tylko i wyłącznie z tego ze UTF-8 chce być kompatybilny ze starymi kodowaniami. Równie dobrze można by NULLa kodować jako 0xDEADBEEF jeśli ktoś miałby taki kaprys

0

@Shalom:

Stary NULL cały czas jest taki sam tylko na poziomie synktaktycznym. Semantycznie wprowadziliśmy nowy znak, którego zapis jest taki sam, jak dawniej zapis starego znaku. A skoro tak, to możemy za chwilę chcieć móc zawrzeć w stringu ten nowy (semantycznie) znak - da się to zrobić stosując dokładnie ten sam trick, jaki przed chwilą zastosowaliśmy ze starym (semantycznie) NULLem, tylko że to prowadzi do zapętlenia.

Wydaje mi się, że nie popełniłem błędu pomieszania semantyki z synktaktyką i niezrozumienia cytowanego tekstu.

Oczywiście wszystko, co napisałeś, jest prawdą, ale nie widzę, w jaki sposób podważa to to, co ja napisałem.

1

Stary NULL cały czas jest taki sam tylko na poziomie synktaktycznym. Semantycznie wprowadziliśmy nowy znak, którego zapis jest taki sam, jak dawniej zapis starego znaku. A skoro tak, to możemy za chwilę chcieć móc zawrzeć w stringu ten nowy (semantycznie) znak - da się to zrobić stosując dokładnie ten sam trick, jaki przed chwilą zastosowaliśmy ze starym (semantycznie) NULLem, tylko że to prowadzi do zapętlenia.

Nie. Nie. Nie. Semantycznie nie istnieje żaden nowy znak. Jest znak null który postanowiliśmy kodować w specjalny sposób i tyle, jednocześnie rezerwując jeden z code-pointów na coś zupełnie innego. Nadal nie rozumiesz różnicy między danymi (w tym przypadku znakiem "null") a kodowaniem czy sposobem zapisu (w tym przypadku np. poprzez 00 albo C0 80). Zmieniono tylko sposób reprezentowania tego konkretnego znaku i nic więcej. To tak jak Niemcy ze swoim ß które próbowali swego czasu zamienić na ss. Nie wprowadzono żadnej "nowej litery". Po prostu zmieniono sposób zapisu i tyle. Trochę tak jakbyśmy od dziś zaczęli w Polsce literkę A pisać o góry nogami, a symbol A zarezerwowali na coś zupełnie innego :)

0

Jak nie.

Znak (na poziomie semantycznym) może być kodowany na różne sposoby (na poziomie synktaktycznym). Jeden znak (semantyczny) może być kodowany na wiele sposobów.

Mamy zatem na poziomie semantycznym:

  1. Stary znak NULL. Kodowany na co najmniej dwa sposoby: tradycyjnie, czyli 00, oraz w MUTF-8 jako C0 80.
  2. Znak, który odpowiada pozycji 00 z MUTF-8. Każdy poprawny zapis synktaktyczny odpowiada jakiemuś znakowi na poziomie semantycznym, a więc 00 w MUTF-8 też jakiemuś musi odpowiadać. Nie jest to stary NULL, a więc jest to coś nowego. Sam zresztą napisałeś: jednocześnie rezerwując jeden z code-pointów na coś zupełnie innego, a więc przyznałeś, że semantycznie wprowadziliśmy nowy byt.

Trochę tak jakbyśmy od dziś zaczęli w Polsce literkę A pisać o góry nogami, a symbol A zarezerwowali na coś zupełnie innego :)

Dokładnie tak, sytuacja jest analogiczna. Tyle, że w tym twoim przykładzie znowu wprowadzilibyśmy nową literę, mianowicie owo coś zupełnie innego, na co zostałby zarezerwowany symbol A.

2

Ten MUTF-8 został wprowadzony na potrzeby serializacji:

In normal usage, the language supports standard UTF-8 when reading and writing strings through InputStreamReader and OutputStreamWriter (if it is the platform's default character set or as requested by the program). However it uses Modified UTF-8 for object serialization[67] among other applications of DataInput and DataOutput, for the Java Native Interface,[68] and for embedding constant strings in class files.[69]

Innymi słowy, MUTF-8 jest na wewnętrzne potrzeby Javy i generalnie nie jest przetwarzany przez kod kliencki bezpośrednio. No chyba, że ktoś bardzo chce albo sam pisze JVMkę. Nie ma też potrzeby, ani sensu, by serializować dane dwukrotnie (a nawet jeśli, to po pierwszej serializacji traktujemy dane jako tablicę bajtów, a nie próbujemy na siłę traktować danych binarnych jako tekstu). Różnic między UTF-8, a MUTF-8 jest więcej niż tylko kodowanie nulla:

There are two differences between this format and the standard UTF-8 format. First, the null character (char)0 is encoded using the two-byte format rather than the one-byte format. This means that modified UTF-8 strings never have embedded nulls. Second, only the one-byte, two-byte, and three-byte formats of standard UTF-8 are used. The Java VM does not recognize the four-byte format of standard UTF-8; it uses its own two-times-three-byte format instead.

Musisz sobie dopisać coś do swojej historyjki, żeby "wyjaśnić" kolejne różnice.

0

Każdy poprawny zapis synktaktyczny odpowiada jakiemuś znakowi na poziomie semantycznym

Nie.

mianowicie owo coś zupełnie innego, na co zostałby zarezerwowany symbol A.

Nie, bo to jest jakiś meta-symbol który w ogóle nie jest częścią alfabetu więc nigdy w tym alfabecie nie wystąpi.

0

Tak samo stary NULL też oryginalnie był tylko jakimś meta-symbolem, a jednak został dołączony do alfabetu. Wskazuje to na możliwość, że "nowy NULL" też może za chwilę zostać dołączony do alfabetu. Z dokładnie tych samych powodów, z których do alfabetu dołączyliśmy "starego NULL"a.

0
Wibowit napisał(a):

Ten MUTF-8 został wprowadzony na potrzeby serializacji:

In normal usage, the language supports standard UTF-8 when reading and writing strings through InputStreamReader and OutputStreamWriter (if it is the platform's default character set or as requested by the program). However it uses Modified UTF-8 for object serialization[67] among other applications of DataInput and DataOutput, for the Java Native Interface,[68] and for embedding constant strings in class files.[69]

Innymi słowy, MUTF-8 jest na wewnętrzne potrzeby Javy i generalnie nie jest przetwarzany przez kod kliencki bezpośrednio. No chyba, że ktoś bardzo chce albo sam pisze JVMkę. Nie ma też potrzeby, ani sensu, by serializować dane dwukrotnie (a nawet jeśli, to po pierwszej serializacji traktujemy dane jako tablicę bajtów, a nie próbujemy na siłę traktować danych binarnych jako tekstu). Różnic między UTF-8, a MUTF-8 jest więcej niż tylko kodowanie nulla:

There are two differences between this format and the standard UTF-8 format. First, the null character (char)0 is encoded using the two-byte format rather than the one-byte format. This means that modified UTF-8 strings never have embedded nulls. Second, only the one-byte, two-byte, and three-byte formats of standard UTF-8 are used. The Java VM does not recognize the four-byte format of standard UTF-8; it uses its own two-times-three-byte format instead.

Musisz sobie dopisać coś do swojej historyjki, żeby "wyjaśnić" kolejne różnice.

OK, to rzeczywiście tłumaczy, dlaczego nie będziemy chcieć przedstawiać "nowego" nulla w innych kodowaniach i dlaczego prawdopodobnie nie dojdzie do zapętlajki.

2

Dodatkowo \c0\80 to nie jest nowy znak. To jest niedozwolone kodowanie już istniejącego nulla:

In Modified UTF-8, the null character (U+0000) uses the two-byte overlong encoding 11000000 10000000 (hexadecimal C0 80), instead of 00000000 (hexadecimal 00).[65]

Poszukałem dekodera UTF-8 online, takiego który poradzi sobie z overlong encoding i znalazłem: https://software.hixie.ch/utilities/cgi/unicode-decoder/utf8-decoder
Wybrałem format hexadecimal, wbiłem ciąg znaków \c0\80 i dostałem:

Decoder output:

Byte number 1 is decimal 192, hex 0xC0, octal \300, binary 11000000
This is the first byte of a 2 byte sequence.

Byte number 2 is decimal 128, hex 0x80, octal \200, binary 10000000
This is continuation byte 1, expecting 0 more.

U+0000	<control>
	= NULL

a więc nie jest to żaden nowy znak, a stary dobry null, tyle że w kodowaniu zabronionym przez (niezmodyfikowany) UTF-8:

Overlong encodings
In principle, it would be possible to inflate the number of bytes in an encoding by padding the code point with leading 0s. To encode the Euro sign € from the above example in four bytes instead of three, it could be padded with leading 0s until it was 21 bits long – 000 000010 000010 101100, and encoded as 11110000 10000010 10000010 10101100 (or F0 82 82 AC in hexadecimal). This is called an overlong encoding.

The standard specifies that the correct encoding of a code point uses only the minimum number of bytes required to hold the significant bits of the code point. Longer encodings are called overlong and are not valid UTF-8 representations of the code point. This rule maintains a one-to-one correspondence between code points and their valid encodings, so that there is a unique valid encoding for each code point. This ensures that string comparisons and searches are well-defined.

0
Wibowit napisał(a):

Dodatkowo \c0\80 to nie jest nowy znak.

Przecież nic innego nie twierdzę w swoich postach.!

2

Hmm, napisałeś:

Wspaniałe rozwiązanie: nasz stary, dobry NUL przenosi się na pozycję 0xC080, natomiast na pozycję 0x00 wstawiamy nowego nulla, który przejmuje funkcję starego nulla, czyli kończy ciąg znaków.

To sugeruje, że 0xc080 jest innym znakiem z alfabetu niż 0x00, a przecież jest dokładnie tym samym, ale inaczej zakodowanym. Po normalizacji do poprawnego UTF-8 oba przymują postać 0x00 i wszystko gra.

Dodatkowo nie ma niejednoznaczności wymagających kolejnych zmian (a'la doubly modified null). W poprawnym UTF-8 null jest kodowany jako 0x00, a nie jako 0xc080. W MUTF-8 jest odwrotnie: null jest kodowany jako 0xc080, a nie jako 0x00.

Zrobiłem krótki eksperyment i wychodzi mi na to, że MUTF-8 jest szczegółem implementacyjnym i to na tyle wąsko używanym, że nawet java.lang.String go nie rozpoznaje (i w środku używa normalnego, poprawnego UTF-16, a nie modified cokolwiek):

package charsets;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.lang.reflect.Field;
import java.util.Arrays;

import static java.nio.charset.StandardCharsets.UTF_8;

public class Overlong {
    public static void main(String[] args) throws Exception {
        final String s = "foo\u0000bar ś";
        System.out.println("standard utf-8:\n" + Arrays.toString(s.getBytes(UTF_8)) + "=" + s);

        String s1 = "foo" + new String(new byte[]{(byte) 0xc0, (byte) 0x80}, UTF_8) + "bar ś";
        System.out.println("modified utf-8 read as standard utf-8:\n" + Arrays.toString(s1.getBytes(UTF_8)) + "=" + s1);

        String s2 = "foo" + new String(new byte[]{(byte) 0x00}, UTF_8) + "bar ś";
        System.out.println("standard utf-8 read as standard utf-8:\n" + Arrays.toString(s2.getBytes(UTF_8)) + "=" + s2);

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        DataOutputStream dos = new DataOutputStream(baos);
        dos.writeUTF(s);
        byte[] modifiedUtf8 = Arrays.copyOfRange(baos.toByteArray(), 2, baos.size());
        System.out.println("modified utf-8:\n" + Arrays.toString(modifiedUtf8));
        System.out.println("modified utf-8 read as standard utf-8:\n" +
                Arrays.toString(new String(modifiedUtf8, UTF_8).getBytes(UTF_8)) + "=" + new String(modifiedUtf8, UTF_8));

        ByteArrayInputStream bais = new ByteArrayInputStream(
                new byte[]{
                        0, 10, 102, 111, 111, 0, 98, 97, 114, 32, -59, -101,
                        0, 11, 102, 111, 111, -64, -128, 98, 97, 114, 32, -59, -101
                });
        DataInputStream dis = new DataInputStream(bais);
        System.out.println("deserialized standard utf-8: " + dis.readUTF());
        System.out.println("deserialized modified utf-8: " + dis.readUTF());

        // works in OpenJDK 8
        Field f = String.class.getDeclaredField("value");
        f.setAccessible(true);
        char[] rawChars = (char[]) f.get(s);
        System.out.println("raw utf-16 as chars:\n" + Arrays.toString(rawChars));
        short[] rawSurrogates = new short[rawChars.length];
        for (int i = 0; i < rawChars.length; i++) {
            rawSurrogates[i] = (short) rawChars[i];
        }
        System.out.println("raw utf-16 as surrogates:\n" + Arrays.toString(rawSurrogates));
    }
}

wypisuje:

standard utf-8:
[102, 111, 111, 0, 98, 97, 114, 32, -59, -101]=foo bar ś
modified utf-8 read as standard utf-8:
[102, 111, 111, -17, -65, -67, -17, -65, -67, 98, 97, 114, 32, -59, -101]=foo��bar ś
standard utf-8 read as standard utf-8:
[102, 111, 111, 0, 98, 97, 114, 32, -59, -101]=foo bar ś
modified utf-8:
[102, 111, 111, -64, -128, 98, 97, 114, 32, -59, -101]
modified utf-8 read as standard utf-8:
[102, 111, 111, -17, -65, -67, -17, -65, -67, 98, 97, 114, 32, -59, -101]=foo��bar ś
deserialized standard utf-8: foo bar ś
deserialized modified utf-8: foo bar ś
raw utf-16 as chars:
[f, o, o,  , b, a, r,  , ś]
raw utf-16 as surrogates:
[102, 111, 111, 0, 98, 97, 114, 32, 347]

Zamiast MUTF-8, Java gdzieś tam w środku (do celów serializacji) mogłaby użyć np. Unicorn UTF-13 (nazwa wymyślona na kolanie, bo przecież można) z piętnastoma kodowaniami NULLa i trzynastoma kodowaniami znaków "ąę" i też by grało. Możesz też zrobić własny mechanizm serializacji (we własnej bibliotece), który stringi kodowałby jako Janusz UTF-33 i też by to niczego nie popsuło w Javie i nie wymagało żadnych zmian w niej.

Ostatnie linijki wyjścia, czyli:

raw utf-16 as surrogates:
[102, 111, 111, 0, 98, 97, 114, 32, 347]

pokazują, że java.lang.String wewnętrznie przechowuje \u0000 jako (char) 0, a więc go nie modyfikuje.

0

@Shalom: @Wibowit

Nie twierdzę, że C080 w MUTF-8 jest nowym znakiem - to jest stary NULL, tylko inaczej zakodowany.

Natomiast 00 w MUTF-8 już, moim zdaniem, jest innym znakiem niż C080 w MUTF-8.

Tak jak pisałem post wcześniej. Dwa znaki:

  • "stary" NULL - C080 w MUTF-8 oraz 00 w UTF-8, ASCII, i pewnie wielu innych kodowaniach;
  • "mutfowy" NULL - 00 w MUTF-8.

Podczas gdy imputujecie mi, że twierdzę, że C080 w MUTF-8 to inny znak, niż 00 w UTF-8. Nie twierdzę.

To jest pierdoła; aż głupio się o nią kłócić. Mimo to ciągnę ten temat, bo trochę nie rozumiem bariery komunikacyjnej. Odnoszę wrażenie, że uporczywie dociera do wszystkich coś trochę innego, niż ja mam na myśli. Nie rozumiem, czemu nie umiem jasno się wyrazić. Jest to wada, nad którą muszę pracować, jeśli chcę być w stanie komukolwiek przekazać swoje myśli (nawet, jeśli są one błędne). Dlatego ciągnę ten temat.

0

Kontynuacja cytatu to

Modified UTF-8 strings never contain any actual null bytes but can contain all Unicode code points including U+0000,[66] which allows such strings (with a null byte appended) to be processed by traditional null-terminated string functions. All known Modified UTF-8 implementations also treat the surrogate pairs as in CESU-8.

Co powinno odpowiedzieć na pytanie (stwierdzenie?)

YetAnohterone napisał(a):

Natomiast 00 w MUTF-8 już, moim zdaniem, jest innym znakiem niż C080 w MUTF-8.

Ciąg MUTF-8 nie może zawierać znaku 00 bo wtedy nie można by takiego ciągu przetwarzać w C (przynajmniej ja to tak rozumiem)

1

Przyglądając się jeszcze mocniej:

Modified UTF-8 strings never contain any actual null bytes

MUTF-8 nie zawiera 0x00 (co już było napisane wielokrotnie w tym wątku).

allows such strings (with a null byte appended) to be processed by traditional null-terminated string functions

Można dokleić 0x00 za ciągiem MUTF-8, a więc wtedy 0x00 nie jest elementem ciągu zakodowanego w MUTF-8. To 0x00 nie jest wypluwane przez koder MUTF-8. 0x00 nie powinno też być podawane do dekodera MUTF-8, bo albo poleci exception albo pojawi się ekstra znak, którego nie było na wejściu do kodera (a przecież po zdekodowaniu chcemy mieć dokładnie to co przed kodowaniem).

Wracając do oryginalnego pytania:

Po co w Javowym MUTF-8 znak NUL jest kodowany jako C0 80?

Bo tak pewnie było wygodnie na samym początku tworzenia platformy Java. Może dużo gotowych metod oczekiwało wtedy 0x00 kończącego stringa? Dzisiaj jest dużo metod przyjmujących długość stringa bezpośrednio i możliwe, że nie trzeba byłoby kombinować z tym nietypowym kodowaniem nulla.

Założenie, że w MUTF-8 trzeba dodać nulla:

nasz stary, dobry NUL przenosi się na pozycję 0xC080, natomiast na pozycję 0x00 wstawiamy nowego nulla, który przejmuje funkcję starego nulla, czyli kończy ciąg znaków.

jest dziwne. Po co? Jak dla mnie nie ma to zupełnie sensu. Kombinują, żeby wywalić 0x00 tylko po to, by go z powrotem wstawić? Przecież nie takie mieli założenia. Kodowanie do MUTF-8 jest całkowicie pod kontrolą Javy, więc to autorzy Javy ustalają zasady i ustalają takie jakie są dla nich wygodne.

Ponadto w UTF-8 0x00 wcale nie kończy stringa. To kończenie stringa jest kontraktem metody, która go przyjmuje. Za https://en.wikipedia.org/wiki/Null_character :

The original meaning of this character was like NOP—when sent to a printer or a terminal, it does nothing (some terminals, however, incorrectly display it as space).
(...)
Today the character has much more significance in C and its derivatives and in many data formats, where it serves as a reserved character used to signify the end of a string,[6] often called a null-terminated string.[7]

Są jednak inne podejścia. W Pascalu stringi często mają na początku zakodowaną długość i nie trzeba szukać żadnego znaku kończącego stringa. DOS zamiast 0x00 używa '$' jako znaku kończącego stringa:
https://stackoverflow.com/questions/481344/dollar-terminated-strings

BITS 32
    global _main

section .data
    msg db "Hello, world!", 13, 10, ’$’

section .text
_main:
mov ah, 9
mov edx, msg
int 21h
ret

Do wyboru, do koloru. Najważniejsze, by sprawdzić w dokumentacji jak metoda się zachowa, a nie wróżyć z fusów i przewidywać przyszłość.

1 użytkowników online, w tym zalogowanych: 0, gości: 1