QVINTVS · SCRIBET

C++, Plattformunabhängig, Unicode und Windows

Bei der Entwicklung plattformunabhängiger C++-Programme spielt Unicode auf eine ganz fiese Art und Weise mit. In diesem Posting werden wir so ganz nebenbei standardwidrige Compiler und Wege um die daraus resultierenden Problematiken kennenlernen.

Einführung

Eigentlich ist Unicode ja eine tolle Sache. Alle Zeichen der Welt speichern und kodieren zu können, das macht was her. Endlich keine Eigenbrötlerei mehr, alles vereint. Naja, eigentlich.

Praktisch gesehen ist Unicode erst einmal nichts weiter als eine Sammlung aller Zeichen, quasi ein riesengroßes Blatt Papier, auf das alle Zeichen dieser Welt einfach der Reihe nach aufgezeichnet sind. Wer mag, kann sich dieses Blatt auch durchaus ansehen. Mehr ist es erst einmal nicht — nur diese Sammlung von Zeichen, ohne jede Spezifikation, wie man sie eigentlich speichert. Unicode wird von Zeit zu Zeit überarbeitet, und es werden weitere Zeichen hinzugefügt; darüberhinaus ist die generelle Policly AFAIK niemals Zeichen aus Unicode zu streichen. Einmal drin, immer drin.

Für Betriebssysteme und Computerprogramme ergibt sich daraus ein recht einsichtiges Problem: Wie speichert man so eine potenziell immer größer werdende Menge von Zeichen? Das Unicode-Konsortium hat dazu drei Zeichenkodierungen entwickelt, die beschreiben, wie man Zeichen in Unicode auf die für Computer verständlichen Bytes mappt:

UTF-8
Die gängigste Kodierung, verwendet von allen modernen unixoiden Betriebssystemen. Die ersten 128 Zeichen sind mit ASCII identisch, was gerade einfachen Anwendungen zugute kommt. Danach werden weitere Zeichen kodiert, indem zunächst angegeben wird, wie viele Bytes für ein Zeichen erforderlich sind, gefolgt von den tatsächlichen Zeichenbytes. UTF-8 ist also ein Encoding variabler Länge. Woher die 8 im Namen kommt, weiß ich nicht :-)
UTF-16
Die populärste Alternative zu UTF-8, verwendet von Microsoft Windows. Die meisten Zeichen werden durch ein einzelnes 16-Bit-Element kodiert, einige wenige benötigen mehrere 16-Bit-Elemente. UTF-16 gibt es in der Variante Little-Endian (UTF-16LE) oder Big-Endian (UTF-16BE), je nachdem, wie herum man die Bytes anordnet. UTF-16 ist im Gegensatz zu UTF-8 weit weniger dynamisch.
UTF-32
Eigentlich das richtige Unicode-Encoding, allerdings de facto von niemandem verwendet. Jedes Zeichen besteht aus genau einem 32-Bit-Element, sonst nichts. Auch UTF-32 gibt es in den Geschmacksrichtungen UTF-32LE und UTF-32BE. UTF-32 ist im Gegensatz zu UTF-8 und UTF-16 von statischer Länge, dafür aber auch recht speicherhungrig. In Anbetracht wachsender RAM-Größen besteht aber die Möglichkeit, dass dies irrelevant wird — und damit wächst das Potenzial von UTF-32 aufgrund seiner Einfachheit im Geheimen. Vielleicht wird es ja einmal die globale Standardkodierung schlechthin.

Weltweit hat man sich heutzutage auf UTF-8 als universales Austauschmittel verständigt. Lediglich im asiatischen Bereich sind, auch aufgrund der anfangs recht stiefmütterlichen Behandlung durch die normenden Stellen in den USA, noch inkompatible, speziell auf asiatische Bedürfnisse zugeschnittene komplexe Eigenentwicklungen im Umlauf, etwa Shift-JIS in Japan oder BIG-5 in China. Das Arbeiten mit solchen Kodierungen ist eine hochkomplexe Angelegenheit und soll hier nicht Thema sein.

Abgesehen davon dominiert UTF-8 die Welt: Es ist die Standardkodierung für so ziemlich alle Webprotokolle, für Dateiaustausch, Websitendarstellung. Und alles könnte so schön sein, ich könnte den Beitrag hier beenden uns sagen: Nehmt UTF-8 und alles ist in Butter. Aber…

Entra Windows

Wer mich kennt, weiß, dass ich ein beflissener Linuxnutzer bin, die Existenz dieses seltsamen Betriebssystems aus Redmond aber nicht leugne und respektiere, wenn andere Leute sich dami abquälen wollen. Leider verwendet Windows wie oben angedeutet intern zur Unicode-Darstellung nicht wie alle anderen Betriebssysteme UTF-8, sondern UTF-16, genaugenommen UTF-16LE, was eine fatale Folge hat: Die C/C++-Standardbibliotheken funktionieren nicht so, wie sie sollen. Das ist schnell an einem Beispiel demonstriert:

clude <iostream>
#include <fstream>

int main(int argc, char* argv[])
{
  std::ofstream file("Bärenstark.txt", std::ios::out | std::ios::trunc);
  file << "Bärenstarker Inhalt" << std::endl;
  file.close();
  return 0;
}

Diese Datei in UTF-8 abspeichern und durch den g++ jagen und ausführen. Auf Linux hat dies den gewünschten Effekt — es ensteht eine Datei Bärenstark.txt im aktuellen Arbeitsverzeichnis. Auf Windows guckt man in die Röhre — ganz egal, ob man mit MinGW den g++ verwendet oder Microsofts Visual C++ Compiler (MSVC). Das Ergebnis ist unleserliches Zeug im Dateinamen, weil (auf einem westlichen Windows) der Dateiname als Windows-1252-kodiert angenommen wird. Und das schönste an der ganzen Sache: Das lässt sich Windows auch beim besten willen nicht anders beibiegen (auf einem nichtwestlichen Windows wäre es statt Windows-1252 das jeweils lokale Encoding).

Problem

Das stellt uns vor ein signifikantes Problem, weil uns auf Windows plötzlich die halbe Standardlibrary wegbricht. Glücklicherweise nur die halbe, denn neben den gewöhnlichen Strings, die man als alter *nix-Programmierer in seinen Programmen verwendet, gibt es noch andere: WStrings, wide strings, vom Typ std::wstring. Hierbei handelt es sich explizit um Multibyte-Strings, bestehend aus wchar_t anstatt char wie der klassische std::string. Wie genau wchar_t aber aussieht, ist völlig plattformabhängig, und wer schon länger für Unix programmiert weiß: Außerhalb der Windows-Welt verwendet niemand wchar_t. Es ist für den praktischen Gebrauch einfach überflüssig, und erfüllt allein auf Windows die Lückenbüßerfunktion für ein vernünftiges, mit UTF-8 funktionierendes API. wchar_t ist auf Windows groß genug, um UTF-16-Zeichen zu halten, und wird eben dafür dort benutzt, oder anders gesagt, std::wstring ist auf Windows ein UTF-16LE-kodierter String.

Bis hierhin ist das Verhalten von Windows nur nervig, aber umgänglich. Wir könnten das obige Programm umschreiben, dass es mit Wide-Strings arbeitet, oder etwa nicht (ein @ L@ vor einem String-Literal bewirkt die Erstellung eines WStrings statt eines Strings)?

clude <iostream>
#include <fstream>

int main(int argc, char* argv[])
{
  std::wofstream file(L"Bärenstark.txt", std::ios::out |
  std::ios::trunc);
  file << "Bärenstarker Inhalt" << std::endl;
  file.close();
  return 0;
}

Jetzt kommt die große Überraschung. Dieser Code kompiliert — aber nur mit dem MSVC. Tatsächlich sieht der C++-Standard keine Möglichkeit vor, einen WString als Parameter zu übergeben, hierbei handelt es sich um nicht standardgerechtes Verhalten des Microsoft-Compilers. Man lasse sich dies auf der Zunge zergehen:

Weil Windows kaputt ist und keine UTF-8-Strings als Argumente für die Standardlibrary-Funktionen versteht, muss Microsoft einen kaputten (nicht standardkonform = kaputt) Compiler liefern, der nicht standardgerechte Funktionen zur Verfügung stellt, die das Problem lösen. Eine wunderbare Taktik um die Portierung von Programmen ziemlich zu erschweren.

Ich persönlich verwende zur Entwicklung unter Windows jedoch nicht den MSVC, sondern den g++ des MinGW-Projekts. Dieser verhält sich standardkonform und bietet die ominöse Funktion mit WString-Argument schlicht nicht an. Diese Problematik wird sehr schön in diesem Thread auf der Boost-Mailingliste dargelegt. Es handelt sich bei diesem Thread um die initiale Ankündigung der nowide-Library, die dem ein oder anderen vielleicht bekannt ist. Bevor ich darauf eingehe, möchte ich jedoch noch einmal kurz und bündig darauf hinweisen, dass ich nicht gewillt bin, wegen Microsofts standardwidrigem Verhalten meine Programme alle auf UTF-16LE umzustellen. Intern arbeite ich mit UTF-8, und basta. Wer eine lange Liste an Gründen sucht, wird mit dieser Seite bestens bedient.

Lösung?

Eine einfache Lösung für das Problem ist nicht in Sicht. An und für sich wäre der einzig richtige Weg, darauf zu warten, dass Microsoft seine API so fixt, dass sie wie jedes andere Betriebssystem auch UTF-8 versteht. Da das aber nicht zu erwarten ist, gilt es, einen Workaround zu finden. Der beste Weg in dieser Hinsicht ist die (auch auf www.utf8everywhere.org empfohlene) nowide-Bibliothek, die momentan jedoch noch ihrer Aufnahme in die offizielle Boost-Distribution harrt. Es ist davon auszugehen, dass mit dem nächsten großen Release von Boost also endlich eine einigermaßen standardisierte (standardisiert durch Gebrauch) Programmbibliothek zur Verfügung steht, die dieses Problem löst. Leider wird es mindestens noch einmal genauso lange dauern, bis diese Lib in Distributionen wie Ubuntu Linux angekommen ist. nowide, dessen Dokumentation online verfügbar ist, definiert neben zahlreichen weiteren Helper-Funktionen vor allem zwei Funktionen: boost::nowide::widen(), was einen UTF-8-kodierten String als Argument entgegen nimmt und es in einen UTF-16LE-kodierten WString verwandelt. Das Gegenstück boost::nowide::narrow() geht den umgekehrten Weg.

Unser Code sähe damit so aus:

clude <iostream>
#include <fstream>                
#include <boost/filesystem.hpp>
#include <boost/nowide.hpp>

int main(int argc, char* argv[])
{
#ifdef _WIN32
  boost::filesystem::path
  pname(boost::nowide::widen("Bärenstark.txt"));
#else
  boost::filesystem::path pname("Bärenstark.txt");
#endif
  boost::ofstream file(pname);
  file << "Bärenstarker Inhalt" << std::endl;
  file.close();
  return 0;
}

Selbstverständlich könnte ich in diesem Trivialbeispiel auch direkt L"Bärenstark.txt" nutzen, aber es geht im größeren Kontext ja um nicht vorhersehbare Nutzereingaben. boost::filesystem::path versteht im Konstruktor sowohl Strings als auch WStrings, und so funktioniert es wie gewünscht.

Dies soll im übrigen nur den grundlegenden Sinn der nowide-Library darlegen. nowide definiert selbst Funktionen, die sich als Drop-In-Replacement für die Standardlibrary verstehen und so die Arbeit mit Dateien weiter vereinfachen.

Zeit

Wie gesagt ist nowide jedoch noch nicht in Boost enthalten. Bis dahin nutze ich die einfachere Eigenimplementation der Konvertierungsfunktionen von SMC, einem Projekt, an dem ich seit einiger Zeit mitarbeite. Dort sehen die Konvertierungsfunktionen so aus:

::string ucs2_to_utf8( const std::wstring &utf16 )
{
        if( utf16.empty() )
        {
                return std::string();
        }

        const int utf8_length = WideCharToMultiByte( CP_UTF8, 0, utf16.data(), utf16.length(), NULL, 0, NULL, NULL );

        if( utf8_length == 0 )
        {
                printf( "Warning: ucs2_to_utf8 : WideCharToMultiByte returned zero length" );
                return std::string();
        }

        std::string utf8( utf8_length, 0 );

        if( !WideCharToMultiByte( CP_UTF8, 0 , utf16.data(), utf16.length(), &utf8[0], utf8.length(), NULL, NULL ) )
    {
                printf( "Warning: ucs2_to_utf8 : WideCharToMultiByte conversion failed" );
    }

        return utf8;
}

std::wstring utf8_to_ucs2( const std::string& utf8 )
{
        if( utf8.empty() )
        {
                return std::wstring();
        }

        const int utf16_length = MultiByteToWideChar( CP_UTF8, 0, utf8.data(), utf8.length(), NULL, 0 );

        if( utf16_length == 0 )
        {
                printf( "Warning: utf8_to_ucs2 : MultiByteToWideChar returned zero length" );
                return std::wstring();
        }

        std::wstring utf16( utf16_length, 0 );
        if( !MultiByteToWideChar( CP_UTF8, 0, utf8.data(), utf8.length(), &utf16[0], utf16.length() ) )
        {
                printf( "Warning: utf8_to_ucs2 : MultiByteToWideChar conversion failed" );
        }

        return utf16;
}

Hierbei handelt es sich um ein 1:1-Zitat aus den Quellen, evtl. möchte man diese Funktionen für den Eigengebrauch ein wenig modifizieren. Insbesondere würde ich die Warnung durch eine Exception ersetzen wollen. UCS-2 ist im Übrigen der Vorgänger von UTF-16, also nicht über die fragwürdige Namensgebung wundern. Die Codebasis ist eben schon ein wenig älter.

Copyright-Hinweis: Dieser Code ist nicht von mir. Er stammt von Florian Richter alias FluXy und ist unter der GNU General Public License, Version 3 oder später, lizenziert. Das Original ist auf GitHub zu finden.

Mythos Locale

Ich weiß nicht, wie oft ich im Laufe meiner Recherche über den Mythos gekommen bin, das Ergebnis der Konversion sei abhängig vom aktuellen Systemlocale. Das stimmt nur teilweise — wenn man wie die obigen Funktionen die Win32API-Funktionen MultiByteToWideChar() und WideCharToMultiByte() verwendet, kann man die Quell- bzw. Zielkodierung explizit angeben, und damit unabhängig vom Locale sein. Es gibt jedoch die Funktionen mbctowcs() und wcstombs() in der C-Standardlibrary, die eine ähnliche Konversion durchführen — allerdings ohne die Möglichkeit, die Kodierung anzugeben. Diese zwei Funktionen nehmen tatsächlich einfach den Locale an, und für diese zwei ist der Mythos tatsächlich richtig.

Einhergehend mit der Locale-Problematik bei der Konvertierung findet man oft auch den Hinweis, den Locale anzupassen, etwa so:

::locale::global(boost::locale::generator().generate(""));

Dies setzt den Locale des Programms auf den Systemlocale, und hat somit selbstverständlich Einfluss auf die Semantik der Konversion. In wie weit mir dies aber bei dem speziellen Problem der Konvertierung eines UTF-8-Strings in das native Dateisystemencoding (im Falle von Windows UTF-16LE) helfen soll, kann ich nicht erkennen.

Mythos Längenangabe

Der absolute Konvertierungsmythos schlechthin ist folgender:

 StringToWString(std::wstring &ws, const std::string &s)
{
    std::wstring wsTmp(s.begin(), s.end());

    ws = wsTmp;

    return 0;
}

Solches oder ähnliches wird in zahlreichen Foren und Wikis vorgeschlagen, dieses spezielle Beispiel habe ich gar von Stackoverflow. Das ist effektiv eine schöne Verpackung um die mbctowcs()-Funktion von C, und ist damit abhängig vom Locale. Außerdem ist es in diesem Code nicht offensichtlich, was passiert, sondern erfordert eine längere Auseinandersetzung mit der Semantik von String-Iteratoren. Daher rate ich von dieser Lösung ab.

Zusammenfassung

Es gibt drei Wege, plattformunabhängig mit UTF-8-kodierten Strings zu arbeiten:

  1. Microsoft fixt seine API. Dies wird wohl nicht passieren.
  2. Durch Verwendung der boost::nowide-Library. Diese wartet jedoch noch auf ihre Aufnahme in das offizielle Boost-Projekt.
  3. Durch ordentliche Benutzung localeunabhängiger Konvertierungsfunktionen wie MultiByteToWideString() in der Win32-API, Beispiele siehe oben.

Insbesondere das standardwidrige Verhalten des MSVC hat meine Suche nach der Problematik deutlich erschwert, da viele Quellen einfach Code posten, der scheinbar bei ihnen funktioniert. Bei meinen Tests mit dem MinGW-Compiler war der g++ jedoch nie in der Lage, die entsprechenden Funktionsprototypen zu finden, und ich war der Verzweiflung nahe. Erst der oben verlinkte Thread des nowide-Autors Artyom Beilis hat schließlich den großen Knoten in meinem Gehirn aufgelöst — dabei hätte man wissen müssen, dass Microsoft wie immer sein eigenes Süppchen kocht. An dieser Stelle verurteile ich all diejenigen die blind Entwicklung für Windows mit Entwicklung mit dem MSVC gleichsetzen — macht die Augen auf! Es gibt auch noch andere Compiler auf dieser Welt!

Valete.