6.18 Manipuler des chaines de caractère en WASM sans se brûler - naver/lispe GitHub Wiki

Introduction

English Version

Bon soyons honnête, l'arrivée du Chat Malodorant (🙀), autrement connu sous le nom de ChatGPT a bouleversé un poil l'environnement dans lequel on bossait tranquillement, les yeux dans la bière chez la... Enfin on connait la chanson. Pourtant, il faut de nouveau revenir à la réalité, et une petite piqure de rappel nous fera du bien à tous. (En passant, je suis assez fan du Chat)

L'informatique en tant que profession existe toujours...

Pour ceux qui ne savent pas, WebAssembly est ce nouveau standard du W3C qui consiste à transformer nos navigateurs préférés en machine virtuelle. Comme disait l'autre: «nihil novi sub sole», « y'a rien de neuf sous la semelle» ou un truc équivalent.

Franchement, «docker» ça ne vous suffisait pas comme source infinie de bogues?

Il a fallu mettre une VM dans le navigateur.

Bon... L'idée en soi en -7A.G. (2015), était bonne, on va étendre les capacités de JS avec du code en C++, C# ou en Rust(re), que l'on va compiler avec LLVM pour générer des biblios que l'on va pouvoir exécuter dans le navigateur.

Franchement, la première partie, la compilation est super simple. T'installe Emscripten et paf!!! T'as plus qu'à remplacer gcc par em++ et roule ma poule.

Vraiment c'est pas plus compliqué que ça... Il suffit de jeter un coup d'oeil sur le Makefile suivant pour s'en convaincre.

Options

Jetons un coup d'oeil aux options de compilation C++ qu'il faut quand même maitriser un tant soit peu.

J'ai compilé un langage à moi, qui s'appelle lispe qui est écrit en C++. J'ai d'ailleurs commis une bafouille à ce sujet.

 -o lispe.html -O3 -sEXPORT_ALL=1 -sWASM=1 -fexceptions -sINITIAL_MEMORY=47972352 -sSTACK_SIZE=20971520
  • -o lispe.html: dans ce cas, il génère aussi un fichier HMTL de test plus un fichier JS de chargement.
  • -O3: Le niveau d'optimisation, vitesse et taille de la bib à la compilation
  • -sWASM=1: Faut bien lui dire que la cible de compilation c'est WebAssembly
  • -fexceptions: Ça c'est pour gérer les exceptions C++. Ça permet aussi d'exporter malloc dans JS.
  • Le reste c'est l'initialisation de la mémoire pour manipuler la bib.

Comme on le voit, pour compiler du C++, ça brûle assez peu de neurones...

Juste un mot en passant sur -o lispe.hmtl, si on remplace par -o lispe.wasm, il ne compile que la biblio WASM.

Faut pas croire au Père Noël

C'est un principe de vie.

Surtout que là, après ça pique un peu.

Parce que de compiler à exécuter, il existe rarement un chemin dégagé en plein soleil, une douce après-midi de printemps. En général, c'est là que les orties et les ronces commencent à envahir le sentier boueux, avec des pointes d'acier dans les nids-de-poule, et une pluie à découper du fer forgé.

On soupire d'aise d'avoir compilé notre biniou et là on découvre la première fois qu'on souffle dedans qu'il y a des trous partout.

Par exemple, WebAssembly ne sait pas ce qu'est une chaine de caractère.

Déchainés

On se dit, d'accord, on va regarder un peu mieux ce qu'est une chaine en JS. C'est la démarche normale, rassurante, mâture.

JS manipule des chaines encodées en... UTF-16. Was... Warum so viel Hass?

Oui, pas de l'UTF-8 ou de l'Unicode bien propret en UTF-32, nan... De l'UTF-16.

Pour le plaisir je vous mets une petite routine que j'ai pondu pour transformer l'UTF-16 en UTF-32...

C'est cadeau...

bool c_utf16_to_unicode(char32_t& r, char32_t code, bool second) {
    //On s'est aperçu que c'était un code sur 32 bits, on rajoute la deuxième partie
    if (second) {
        r |= code & 0x3FF;
        return false;
    }
    
    //si le premier octet est 0xD8000000, il s'agit d'un encodage sur quatre octets
    if ((code & 0xFF00) == 0xD800) {
        //Vous aimez, c'est beau non?
        r = ((((code & 0x03C0) >> 6) + 1) << 16) | ((code & 0x3F) << 10);
        return true;
    }
    
    //sinon r est le même qu'en UTF-32
    r = code;
    return false;
}

En fait, j'ai déjà oublié comment j'ai pu écrire un jour ce code... Le sport c'est une saloperie... Faut pas commencer...

Tableau de nombre

On cherche, on s'interroge, on se désespère de comprendre un jour les explications sur StackOverflow et parfois on découvre des bribes d'explication. (De toute façon, StackOverflow est une punition divine destinée à ceux qui croient encore que l'informatique s'apprend auprès des démons du 9ième cercle).

Par exemple, pour passer une chaine de caractère à WebAssembly, faut la passer sous la forme d'un tableau d'entier.

Mais attention (revoir remarque sur le Père Noël), faut que le tableau de nombre soit déclaré dans l'espace commun avec la biblio WASM.

Ok... Ça donne quoi?

Là aussi c'est cadeau (ces fonctions sont présentes dans lispe_functions.js):

function convertiChaineEnInt32(code) {
    //On se donne un peu d'espace supplémentaire
    nb = code.length + 1;
    nb = Math.max(20, nb);

    //d'abord on crée notre tableau d'entier
    arr = new Int32Array(nb);
    //dans lequel on range notre chaine, caractère par caractère
    for (i = 0; i < code.length; i++) {
        arr[i] = code.charCodeAt(i);
    }
    arr[code.length] = 0;
    //Puis là, on alloue un tableau de nb*4 octets
    //Un Int32 est enregistré sur 4 octets
    a_buffer = Module._malloc(nb * 4);
    //On y range les valeurs dans notre tableau
    //Notez la division par 4 de a_buffer (>> 2)  afin d’obtenir l’index correct dans le tableau HEAP32.
    //Encore une fois un Int32 est sur 4 octets.
    Module.HEAP32.set(arr, a_buffer >> 2)
    return a_buffer;
}

Et y'en a qui disent que JS c'est un langage de moineau.

Mais, là où c'est encore plus sympa, c'est pour faire l'opération inverse:

//sz est le nombre d'éléments dans le tableau: array
function arrayVersChaine(array, sz) {   
    str = "";        
    sz *= 4;
    //Chaque élément est enregistré sur 4 octets
    //Même chose, on divise notre tableau par 4 pour avoir son adresse exacte
    //Et on se balade 4 octets par 4 octets, que l'on transforme chacun en caractère
    for (let pointer=0; pointer < sz; pointer+=4) {
        str += String.fromCharCode(Module.HEAP32[pointer + array>>2]);
    }
    //Et on n'oublie pas de le libérer
    Module._free(array);
    return str;
};

Et du côté de chez C++

Là le paysage s'éclaire, tout devient simple, tout n'est plus que calme et tranquillité... Enfin, un langage où les concepts les plus ardus de l'informatique trouvent leur illustration la plus simple, la plus claire, la plus limpide. (On manque sérieusement de programmeurs C++, faut encourager les plus jeunes)

Voici donc à quoi ressemble le code (voir mainwasm.cxx)

//Tout d'abord EMSCRIPTEN_KEEPALIVE indique que cette fonction est exportée par lispe.wasm
//Et est donc accessible par JS
//Notre fonction retourne une chaine sous la forme d'un tableau d'éléments int32_t
//dont size contiendra la taille...  
EMSCRIPTEN_KEEPALIVE int32_t* eval_lispe(int32_t* str_as_int, int32_t sz, int32_t* size) {
    string cde;
    //s_utf16_to_utf8 est une fonction (voir https://github.com/naver/lispe/blob/master/src/tools.cxx)
    //qui convertit notre tableau d'entier en une chaine encodée en UTF-8
    s_utf16_to_utf8(cde, str_as_int, sz);

    //On exécute notre code
    Element* executed_code= global_lispe()->execute(cde, ".");
    //On récupère la réponse sous la forme d'une chaine en UTF-32
    u32string reponse = executed_code->asUString(global_lispe());
    //On nettoie notre réponse
    executed_code->release();
    
    //reponse est encodée en UTF-32 (c'est comme ça en LispE)
    //On doit la convertir en UTF-16
    wstring result;
    //On dispose aussi de telles méthodes (voir tools.cxx)
    s_unicode_to_utf16(result, reponse);
    sz = result.size();
    //on conserve la taille de notre chaine dans size
    size[0] = sz;
    
    //On se crée alors un tableau dont la taille est celle de notre chaine
    int32_t* value_as_int = new int32_t[sz];
    //On y range nos caractères en tant qu'entier sur 32 bits
    for (long i = 0; i < sz; i++) {
        value_as_int[i] = result[i];
    }
    
    //Et on retourne le tableau en question
    return value_as_int;
}

J'avais dit que ça piquait les yeux.

Grosso-modo, la partie compliquée de ce code, c'est la conversion du tableau en chaine de caractère, puis la conversion de la réponse en tableau d'UTF-16, dont la taille est renvoyée dans la variable size.

Voyons maintenant ce qui se passe de l'autre côté en JS dans le fichier lispe_functions.js:

function callEval(code) {
    //D'abord on récupère le pointeur vers notre fonction dans lispe.wasm
    entryFunction = Module['_eval_lispe'];

    //code est une chaine de caractère que l'on convertit en tableau de nombres
    string_to_array = convertiChaineEnInt32(code);
    
    //Il s'agit d'un tableau de deux éléments qui nous servira à récupérer la taille du tableau de retour
    the_size = provideIntegers(2);

    //On exécute notre fonction, avec les arguments attendus par C++
    //C++: int32_t* eval_lispe(int32_t* str_as_int, int32_t sz, int32_t* size)
    var result = entryFunction(string_to_array, code.length, the_size);
    //On libère notre tableau contenant la chaine initiale
    Module._free(string_to_array);
    //decode_size est une petite routine dans lispe_functions.js qui récupère la taille placée là par la fonction C++
    sz = decode_size(the_size);
    //arrayVersChaine va transformer notre tableau de nombre en chaine de caractère
    //Notez que result est aussi libéré dans arrayVersChaine...
    return arrayVersChaine(result, sz);
}

J'admets que c'est un peu lourd à digérer. Ici, on appelle notre méthode C++ eval_lispe qui prend des tableaux de nombre en entrée ainsi que deux arguments, le premier est la taille du tableau et le second un petit tableau pour y ranger la taille du tableau en retour.

La fonction C++ renvoie à son tour un tableau de nombre, dont la taille est dans size, de cette façon, on peut retrouver la chaine calculée par LispE.

Conclusion

La manipulation de chaines dans lispe.wasm n'a rien de trivial. Elle nécessite de comprendre un peu ce que l'on fait. Mais avec les exemples fournis ici, vous devriez pouvoir vous débrouiller pour manipuler ces échanges sans difficulté.

L'intérêt de WebAssembly n'est plus à démontrer. Je vous conseille d'ailleurs d'essayer pour vous amuser l'exemple que j'ai placé dans exemple.