Strings et PChars en Delphi

Adrien Reboisson

Lorsqu'un ami qui écrivait des DLL renvoyant des chaînes de caractères passées sous la forme de PChar commença à trouver normal que Delphi lève des violations d'accès où renvoie parfois des valeurs incohérentes (mettant cela sur le dos d'une gestion des chaînes approximatives de Windows, bien sûr !), j'ai finalement décidé de terminer ce petit document destiné à éclaircir les idées de certains Delphistes. Il n'a pas vocation d'expliquer en totalité les mécanismes utilisés pour gérer les différents types de chaînes que vous pourrez rencontrer en tant que programmeur Delphi, mais pourra vous éclaircir les idées et vous permettre d'éviter les erreurs courantes lors des conversions de chaînes Delphi en chaînes C.

- Titre : Strings et PChars en Delphi

- Auteur : Adrien Reboisson

- Parution : 9 août 2005

- Mise à jour : 7 décembre 2019

- Licence : Copyright ® 2005 Adrien Reboisson. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Généralités

- I-A. Les chaînes Delphi

- I-B. Les chaînes C

- I-C. Quelques précisions sur le type string

- I-D. Duplication de chaînes

- II. Le type PAnsiChar et l'opérateur PChar()

- II-A. Présentation et règles essentielles

- II-B. À propos de l'allocation mémoire

- II-C. DLL et PAnsiChars

- III. Allocation et utilisation de chaînes C

- III-A. Allocation sur la pile

- III-B. Allocation sur le tas

- III-C. Manipulation de PAnsiChars

- III-D. Allocations intermodules

- IV. Conclusion

I - Généralités

I-A - Les chaînes Delphi

Le type AnsiString (ou tout simplement string) représente le type de chaîne par défaut de Delphi. Son usage est extrêmement courant et tout à fait intuitif : pas de nécessité d'allouer de la mémoire, de dimensionner la variable… tout est géré, en interne, par Delphi :

procedure TForm1.Button1Click(Sender: TObject);
var
S: string;
begin
S := 'Hello, world'; //Hello, world affecté à S
ShowMessage(S);
S := 'Bonjour, monde'; //Changement de contenu..
ShowMessage(S);
S := S + '!'; //Concaténation...
ShowMessage(S);
[...]
end;

Si vous connaissez d'autres types de chaînes, ou venez du monde C, vous serez sans doute heureux de travailler avec le type string. Comme dans l'exemple ci-dessus, l'affectation, la modification ou la concaténation se fait de manière naturelle grâce aux opérateurs du langage.

I-B - Les chaînes C

Dans un monde idéal (tout du moins, un monde de Delphistes !), nous n'aurions besoin que du type AnsiString. Tout serait relativement intuitif et nous n'aurions plus besoin de nous soucier de comment coder les chaînes de caractères. Hélas, comme vous le savez sans doute, nous vivons dans un monde imparfait… tout du moins, relativement hétérogène. D'autres langages cohabitent avec le Pascal, dont un qui a servi (et sert encore) de « dénominateur commun » et de référence aux autres : le C. Windows est en partie écrit en C, et les API qu'il met à la disposition des programmeurs attendent des types compatibles avec ce langage.

En C, une chaîne est représentée par un pointeur vers un tableau de caractères terminé par le caractère de code ASCII zéro(1).

Autrement dit, une variable déclarée comme « chaîne » dans ce langage est avant tout un pointeur, c'est-à-dire un nombre qui désigne un emplacement dans la mémoire du programme. Ce nombre pointe vers une zone de mémoire constituée d'octets contigus (un tableau) représentant la chaîne. Celle-ci est obligatoirement terminée par le caractère ASCII de code zéro (2).

Dans le cas d'une chaîne « str » stockant « Hello » en mémoire, on pourrait avoir la configuration suivante :

Cette optique possède un grand avantage : sa simplicité de conception. Une chaîne est un pointeur sur un tableau, point barre. Rien n'est implicitement géré par le langage, ce qui apporte au moins un bénéfice : il n'y a aucun bagage supplémentaire servant à gérer automatiquement certaines caractéristiques de la chaîne comme en Delphi. Par conséquent, pour qu'un autre langage exploite le contenu d'une chaîne C, il « suffit » de lui transmettre l'adresse de la zone contenant la chaîne, et c'est tout. Travailler avec une chaîne C consiste généralement à recevoir l'adresse de la zone, correspondant à l'adresse du premier caractère de la chaîne (regardez le schéma ci-dessus au besoin), et à traiter les caractères jusqu'à que le caractère de code ASCII zéro soit rencontré.

Il y a néanmoins de nombreux inconvénients à cette représentation. Puisque les chaînes C ne sont pas intrinsèquement gérées par le langage comme en Delphi, les opérations habituelles (assignation de texte, concaténation, extension…) doivent être programmées « à la main ». Par exemple, la concaténation d'une chaîne avec une autre (Chaine1 + Chaine2) va consister (3) :

- à se positionner sur le premier caractère de la première chaîne (la case du tableau d'index 0) ;

- à se positionner sur le premier caractère de la seconde chaîne ;

- avancer dans la première chaîne jusqu'à atteindre le caractère #0, représentant la fin de la chaîne ;

- avancer dans la première chaîne, copier dans cette dernière la valeur située à la position courante de la seconde chaîne, avancer dans les deux chaînes jusqu'à atteindre le caractère #0 de la seconde chaîne ;

- placer le caractère #0 en fin de la première chaîne pour mettre à jour sa fin.

… tout cela, bien sûr, en supposant que la première chaîne ait été assez dimensionnée pour pouvoir recevoir la concaténation des deux objets. Par rapport à la simple utilisation du « + » en Delphi, c'est une autre paire de manches (4) !

Il y a un autre problème. Si la chaîne contient autre chose que des caractères ASCII (et est par exemple utilisée pour stocker des données binaires), le fait d'affecter des données contenant déjà le caractère #0 perturbera le fonctionnement normal du programme puisque toutes les routines de gestion de chaînes « verront » une fin prématurée et ne traiteront pas l'intégralité de celle-ci. Notez toutefois qu'utiliser une chaîne pour stocker des données binaires consiste plus en un détournement qu'une solution viable, mais c'est parfois une solution rapide (à déconseiller) choisie par certains développeurs. Dans ce cas, les fonctions devant exploiter des chaînes présentant ces contraintes devront recevoir en paramètre une variable supplémentaire représentant la taille de la chaîne. Ainsi, le caractère #0 pourra ne plus être utilisé comme délimiteur exclusif de fin de chaîne.

I-C - Quelques précisions sur le type string

Maintenant que vous connaissez mieux (si ce n'était pas déjà le cas !), la manière dont fonctionnent les chaînes C, revenons quelques instants sur le type équivalent en Delphi.

Par souci de compatibilité et bien que vous ne le voyiez pas, une chaîne Delphi est aussi un pointeur vers une chaîne terminée par le caractère zéro. Mais d'une part, l'aspect « pointeur » est tout à fait implicite (avez-vous déjà à utiliser l'opérateur « ^ » sur une chaîne ? J'en doute !), d'autre part, le #0 est ajouté automatiquement et n'est en fait pas utilisé par Delphi pour déterminer la longueur de la chaîne. Jusqu'ici néanmoins, les deux représentations restent compatibles.

Cependant, Delphi utilise d'autres données… situées à un décalage négatif par rapport au début de la chaîne pointée. Autrement dit, avant de trouver le premier caractère de la chaîne (celui-là même désigné par le pointeur), se trouvent deux champs de 32 bits utilisés en interne par Delphi pour gérer la chaîne :

- Un compteur de longueur qui, comme son nom l'indique, contient le nombre de caractères utilisés par la chaîne. Cela explique donc que le #0 en fin de chaîne soit inutilisé. En découlent deux conséquences :

- Une chaîne Delphi peut stocker n'importe quelles données, même binaires, puisqu’aucun caractère n'est utilisé dans la chaîne pour marquer sa fin,

- La taille maximum d'une chaîne Delphi correspond à la valeur maximum autorisée pour ce champ. Pour une variable de 32 bits, on a donc une taille maximum correspondante d'environ 4 gigaoctets.

- Un compteur de références utilisé pour gérer l'allocation mémoire. Puisque ce type peut contenir de gros volumes de données… autant les allouer de manière plus ou moins « intelligente ». Ainsi, lorsqu'une chaîne est déclarée, son pointeur est assigné à nil, ce qui représente la chaîne vide (‘'). Lorsqu'un texte est affecté à la chaîne la première fois, il y a copie et mise à 1 du compteur de références. Maintenant, si cette variable est assignée à une autre variable de type string, au lieu de faire une copie inutile (à ce stade, les deux variables pointant le même texte !)… le pointeur de la seconde chaîne va pointer la première chaîne. Aucune copie n'est faite, mais le compteur de références est incrémenté et passe à 2. Si la seconde chaîne est maintenant modifiée, elle ne peut plus pointer sur la première : on effectue la copie (5) obligatoire et on décrémente le compteur de référence. Enfin, le compteur de références est également décrémenté lorsque la variable sort de la portée dans laquelle elle a été déclarée (par exemple, lorsque la chaîne est déclarée comme variable temporaire dans une fonction et que cette dernière est quittée). Lorsque ce compteur atteint zéro, la mémoire qui lui est attribuée est libérée. Ainsi donc fonctionne cette gestion automatique dans laquelle vous n'avez encore une fois (heureusement) aucun besoin d'intervenir.

Voilà un schéma de la structure interne d'une variable string :

Voilà donc pourquoi il n'est pas possible de passer directement une chaîne de caractères de type string à une fonction C. Cela nécessiterait que ce langage sache exactement prendre en compte le fonctionnement décrit ci-dessus, ce qui n'est pas le cas, et oblige à des conversions.

Une remarque pour finir : bien qu'il soit possible d'insérer dans des chaînes Delphi (AnsiString) le caractère #0, soyez tout de même vigilant dans son usage? En effet, lorsque vous appelez des fonctions de la VCL il n'est pas rare qu'elles passent la main à des API Windows, qui eux, exigent des chaînes C. Par conséquent, le caractère inséré joue réellement le rôle de fin de chaîne !

Prenons l'exemple suivant :

procedure TForm1.Button2Click(Sender: TObject);
var
NiceString: string;
begin
NiceString := 'Hello, world !';
NiceString[2] := #0;
ShowMessage(NiceString);
end;

Seul « H » sera affiché, car ShowMessage va convertir la chaîne en chaîne C grâce à l'opérateur PChar() (dont le fonctionnement sera détaillé dans le chapitre suivant), comme le montre l'extrait de Dialogs.pas :

DrawText(Canvas.Handle, PChar(Msg), Length(Msg)+1, TextRect,
DT_EXPANDTABS or DT_CALCRECT or DT_WORDBREAK or
DrawTextBiDiModeFlagsReadingOnly);

Donc gardez à l'esprit qu'insérer des #0 aura des effets bien réels sur la chaîne dès qu'elle aura à être convertie en chaîne C.

I-D - Duplication de chaînes

La copie des chaînes étant effectuée au moment de l'écriture, à un moment particulier il se peut que plusieurs variables chaînes désignent la même zone mémoire.

Si vous modifiez une des variables qui la pointent, cela va normalement entraîner la copie de la chaîne modifiée dans une nouvelle zone de mémoire « indépendante ». Par contre, si vous faites cette modification dans un bloc qui n'implémente pas cette gestion automatique (par exemple une routine codée en assembleur), vous risquez de modifier les valeurs de toutes les variables désignant la suite de caractères et pas uniquement la variable string désirée !

Si vous désirez modifier le contenu de la chaîne en étant sûr de ne modifier que la chaîne voulue et non pas une autre instance pointant sur la même suite de caractères, vous devez à ce moment utiliser la procédure UniqueString(), prenant en paramètre la chaîne à dupliquer.
Voilà une illustration très simple de son fonctionnement :

var
A, B: AnsiString;
begin
À := 'Hello World';
B :=A;   //A and B désignent la même zone mémoire
UniqueString(B);  //Crée une nouvelle copie de "Hello World" à une autre nouvelle adresse

La chaîne dupliquée peut alors par exemple être manipulée par une routine assembleur en toute sécurité.

Le problème est également le même lorsque vous convertissez une chaîne Delphi en chaîne C et la passez à une fonction qui la modifie. Les chaînes C n'implémentant aucun comptage de références, la modification de la chaîne dans la fonction aura des répercussions sur toutes les variables pointant vers cette zone de caractères. Un appel à UniqueString sera nécessaire, mais comme on le verra ultérieurement il est fort déconseillé, lorsqu'une fonction attend une chaîne C à modifier, de lui passer le résultat d'une conversion en paramètre. Finalement, UniqueString sera rarement (voir jamais) utilisé dans la majorité des cas.

II - Le type PAnsiChar et l'opérateur PChar()

II-A - Présentation et règles essentielles

Une chaîne C est un pointeur vers un tableau de caractère. Théoriquement, le type Delphi correspondant est donc un pointeur vers un array[0..n] of Char(6). Un type au nom plus court a été introduit pour désigner ceci : c'est le fameux PAnsiChar (ou PChar).

Insistons encore. Un PAnsiChar n'est qu'un pointeur. Comme tout pointeur, de nombreuses précautions doivent être prises pour sa manipulation. Il n'est jamais garanti qu'un pointeur pointe sur quelque chose de valide : dans ce cas, toute opération avec lui peut générer des résultats incohérents? En particulier les fameuses Violations d'accès !

Lorsqu'une fonction C attend un pointeur sur une chaîne de caractère C, il est possible de « transformer » une chaîne de caractère Delphi en pointeur sur chaîne de caractère C (7) que pourra traiter la fonction appelée grâce à l'opérateur PChar()(8). Peu de débutants saisissent néanmoins son action réelle. Lisez et relisez autant de fois nécessaires la ligne suivante :

PChar() ne renvoie qu'un pointeur, il ne crée absolument rien, n'alloue ni ne réserve aucune donnée.

Rappelez-vous la structure du type AnsiString : la taille de la chaîne, un compteur de référence, la chaîne puis le caractère de fin de chaîne #0. Transformer une AnsiString en chaîne de caractère C est très simple : c'est tout simplement renvoyer l'adresse de la chaîne, puisque les deux premières données maintenues par Delphi précédent son début. Si vous n'êtes pas convaincu, vous pouvez exécuter le code suivant, affichant l'adresse d'une chaîne et celle de sa conversion en PAnsiChar. À l'exécution, les deux mêmes nombres sont affichés :

procedure TForm1.Button1Click(Sender: TObject);
var
S: string;
P: PChar;
begin
S := 'Hello !';
MessageDlg(IntToStr(Longword(S)), mtWarning, [mbOK], 0);
P := PChar(S);
MessageDlg(IntToStr(Longword(P)), mtWarning, [mbOK], 0);
end;

Une conclusion s'impose clairement :

Modifier une chaîne C générée via PChar() est dangereux.

En effet, écrire un PAnsiChar modifie la chaîne pointée gérée par Delphi sans mettre à jour les informations de statut (compteur de référence et surtout longueur de chaîne). Il en résulte la règle suivante :

En règle générale, lorsqu'une chaîne C doit être modifiée, elle ne doit pas être créée via PChar().

Si vous y tenez vraiment, vous pouvez néanmoins le faire (le cas échéant, vous pouvez sans remords passer cette section). La procédure est la suivante :

- Il faut dimensionner la chaîne AnsiString de manière explicite grâce à SetLength() en fonction de la taille maximale que devrait renvoyer la fonction utilisant la chaîne. La mémoire est allouée pour contenir le nombre de caractères passé en paramètre à SetLength, le nombre de caractères est mis à jour tout comme le délimiteur (#0) de fin de chaîne (et c'est une des rares fois où SetLength() a besoin d'être utilisé sur un type AnsiString !)

- On obtient ensuite de manière naturelle un pointeur utilisable avec PChar() que l'on passe à la fonction qui en a besoin.

- La nouvelle taille de la chaîne n'étant pas remise à jour après l'appel, il faut de nouveau utiliser SetLength() sur la chaîne pour la redimensionner.

Attention, de cette manière, vous avez réglé le problème de la mise à jour du champ gérant la taille de la chaîne, mais il ne faut pas oublier le second champ éliminé lors de la conversion : celui du compteur de références ! Relisez si besoin le paragraphe évoquant la duplication des chaînes avec UniqueString. La conversion en PChar inhibe le système de duplication automatique lors de la modification : si la chaîne passée en paramètre à un compteur de références supérieur à 1, c'est-à-dire que plusieurs variables pointent vers la chaîne de caractères passée en paramètre, toutes les variables reflèteront la chaîne modifiée. Par conséquent, avant un appel à la fonction modifiant la chaîne, il est préférable d'utiliser UniqueString, sauf si la chaîne n'est jamais affectée à une autre chaîne, explicitement ou implicitement.

Cette manière de faire est illustrée par l'exemple suivant, tiré de l'aide Delphi :

var
S: string;
begin
SetLength(S, MAX_SIZE);  // avant de transtyper en PChar, vérifiez que la chaîne n'est pas vide
SetLength(S, GetModuleFilename(0, PChar(S), Length(S)));
end;

Néanmoins, étant donné les effets de bords et les précautions qu'il faut prendre, je vous déconseille d'utiliser cette technique.

Une autre erreur courante est de renvoyer un PAnsiChar. Voici un exemple de ce qu'il ne faudrait pas faire :

Function GetSomething: Pchar;
var
S: string;
begin
S := Function1;
Result := Pchar(S);
end;

Ici, le résultat d'une fonction locale est assigné à une variable AnsiString « S ». Celle-ci est alors convertie en PAnsiChar puis renvoyée. Où est le problème ? S est une variable locale dont l'existence n'est valide que dans la fonction. PChar() renvoie l'adresse de S, c'est donc cette valeur qui sera renvoyée à l'appelant? Or S n'existant plus, on court à la catastrophe. En résulte une nouvelle règle :

Une fonction ne doit jamais renvoyer de pointeur rendu par un PChar() effectué sur une variable locale ou hors d'atteinte de la fonction exploitant le résultat.

II-B - À propos de l'allocation mémoire

Le type PAnsiChar est, on l'a dit plus haut, équivalent à un pointeur. C'est-à-dire un entier stockant une adresse, et c'est tout. Comment assigner une valeur à un PAnsiChar ? La procédure est la suivante :

- Allouer manuellement ou automatiquement une zone dans la mémoire,

- Assigner son adresse au pointeur,

- Accéder aux « cases » mémoires de la zone pour lire ou écrire des caractères (Delphi propose des fonctions à cet usage),

- Libérer la zone (et ne plus utiliser le pointeur, car il ne désigne plus rien de valide).

Cette procédure est détaillée à la section III, mais dès lors il est facile de voir ce qu'il ne faut pas faire :

var
P: PChar;
begin
P := 'Hello !';

Dans l'exemple suivant, P pointe maintenant vers l'adresse stockant la chaîne « Hello ». La durée de cette vie étant gérée par Delphi, P n'est pas garanti pointer sur quelque chose de valide une fois que celle-ci sera hors d'atteinte.

II-C - DLL et PAnsiChars

Les PAnsiChars sont largement exploités lorsque des librairies non Delphi (ou même Delphi) doivent être utilisées. En effet, dans une application Delphi, il n'y a normalement pas d'intérêt de se passer de l'avantageuse gestion automatique des données effectuée par le type AnsiString.

Lorsqu'on en sort ? Les choses se compliquent légèrement. Il existe de nombreux modules avec lesquels Delphi peut communiquer (je pense notamment aux objets COM), mais par souci de facilité je m'en tiendrais aux classiques librairies dynamiques (DLL). Il reste possible de passer des chaînes Delphi à ces entités, à la condition expresse d'inclure une unité supplémentaire et de redistribuer une DLL qui permet de partager l'allocation mémoire entre les deux modules? Pour cette raison et aussi, car cela ferme l'usage des librairies qui exploitent cette technique à d'autres langages, il vaut mieux s'en tenir au classique passage d'un pointeur vers une chaîne de caractères terminée par le caractère #0 : nos fameux PChars.

Pour l'instant, nous allons nous contenter d'écrire une fonction affichant une chaîne reçue. Notez que je respecte les règles énoncées ci-dessus, en particulier que je ne tente pas de modifier la chaîne reçue dans la DLL !

Côté DLL :

procedure ShowNiceMessage(AString: PChar); stdcall;
begin
MessageDlg(AString, mtWarning, [mbOK], 0);
end;

Côté application :

procedure TForm1.Button7Click(Sender: TObject);
var
S: string;
begin
S := 'Hello world !';
ShowNiceMessage(PChar(S));
end;

Notez encore une fois côté DLL la conversion automatique du PChar en string lors de l'appel à MessageDlg.

III - Allocation et utilisation de chaînes C

III-A - Allocation sur la pile

Pour l'instant, nous avons éclairci l'usage de l'opérateur PChar(), souvent utilisé à tort et à travers et source de nombreuses erreurs inexpliquées. Il est temps de voir comment allouer de « vraies » chaînes C qui ne sont pas simplement des conversions de chaînes Delphi existantes.

Lorsque des chaînes doivent être passées uniquement en lecture, il n'y a pour l'instant aucun problème : PChar() se suffit à lui-même. Par contre, dès qu'une chaîne doit être modifiée en écriture, les choses se compliquent du fait que la valeur délivrée par PChar() correspond à une chaîne en réalité gérée par Delphi. La solution est simple : il faut ne plus utiliser des chaînes Delphi et revenir à une représentation C traditionnelle. Par conséquent, il faut en Delphi définir une variable de type array [0..n] of char (équivalent du tableau de caractères en C), et envoyer ce tableau à la fonction qui doit le remplir.

Il y a néanmoins un problème à résoudre? Imaginons qu'une très grosse chaîne doit être renvoyée alors que le tableau ne comporte que 5 caractères (il a été défini comme array [0..4] of char). La fonction n'a reçu qu'un pointeur, est la zone pointée n'est pas forcément initialisée : celle-ci n'a donc aucun moyen de contrôler la taille de la zone et annuler la copie. Trop de caractères seront copiés et une partie de la chaîne écrasera peut-être des données existantes, situation qu'il est très souhaitable d'éviter.

Pour cette raison, les fonctions qui doivent modifier une chaîne attendent souvent pour chaque chaîne à modifier deux paramètres : un pointeur sur la chaîne, et également un entier contenant le nombre maximal de caractères qu'elle peut contenir, c'est-à-dire en fait le nombre d'octets qui lui ont été alloués au départ. De cette manière, il est facile de contrôler avant la copie qu'on ne « débordera » pas en ajoutant de nouvelles données dans la chaîne.

N'oubliez pas de compter le caractère de fin de chaîne que lorsque vous allouez un tableau de caractères devant accueillir une chaîne C. Par exemple, si vous souhaitez qu'un tampon puisse contenir au maximum une chaîne de 50 caractères, vous devez réserver 51 octets, le dernier servant de marqueur de fin de chaîne. En Pascal, cela reviendrait à allouer un array [0..50] of Char.

Prenons l'API GetModuleFileName(). Peu importe son rôle, retenons simplement qu'elle doit renvoyer une chaîne, et que ses deux derniers paramètres correspondent respectivement à un pointeur sur la chaîne à modifier, et à la taille maximale que peut contenir la chaîne. Voilà un exemple d'utilisation permettant de l'appeler et de renvoyer son résultat sous forme de string :

function TForm1.GetFileName: string;
var
LFileName: array [0..1023] of char;
begin
GetModuleFileName(0, LFileName, SizeOf(LFileName));
Result := LFileName;
end;

Rappelons que SizeOf() renvoie le nombre d'octets occupés par une structure. Un char occupant un octet, la valeur renvoyée sera également le nombre de caractères alloués pour la chaîne (ici 1024).

III-B - Allocation sur le tas

Notez ici que les données (le tableau) ont été allouées statiquement sur la pile. Cet espace de stockage n'est pas infini (9) et lorsque des tampons de valeur plus importante doivent être alloués, il est parfois plus sûr d'utiliser le tas (10) pour éviter tout « Débordement de pile » (Stack Overflow). Dans ce cas, on doit utiliser une fonction d'allocation mémoire telle que AllocMem() ou GetMemory(), renvoyant un pointeur sur une zone de taille définie. On utilise une éventuellement une fonction permettant d'initialiser cet espace à partir d'une chaîne Delphi (tel StrCpy()). On passe ensuite ce pointeur à la fonction désirée. Enfin, on n'oublie surtout pas de libérer la mémoire allouée au tampon avec, par exemple, la fonction FreeMemory()(11) (puisque tout ce qui est alloué manuellement sur le tas doit être désalloué manuellement en fin d'utilisation). Voici un exemple de la même fonction que précédemment utilisant un tampon alloué sur le tas :

function TForm1.GetFileName2: string;
const
cstMaxChars = 4096; //taille du tampon
var
LFileName: PChar; //pointeur sur le tampon
Begin
//1 allocation de la zone via GetMemory
LFileName := GetMemory(cstMaxChars * SizeOf(Char));
//2 Appel de la fonction
GetModuleFileName(0, LFileName, cstMaxChars);
//3 Conversion implicite et automatique en AnsiString
Result := LFileName;
//4 Libération de la mémoire
FreeMemory(LFileName);
end;

Il est possible également d'utiliser d'autres fonctions Delphi qui font à peu près la même chose, à savoir StrAlloc() (ou StrNew()(12) ) et StrDispose(). Le principe est le même : allocation d'une zone mémoire sur le tas destiné à contenir une chaîne via StrAlloc(), puis libération via StrDispose(). Notez néanmoins que StrAlloc() stocke en plus dans l'octet précédant la zone le nombre d'octets qui lui ont été initialement alloués : cela permet de ne plus tenir exclusivement compte du délimiteur de fin de chaîne #0 et ainsi de stocker tout type de données binaires ; la taille maximale allouée pouvant être récupérée à l'aide de StrBufSize(). Il va de soi que du fait de ce comportement particulier, pour que l'espace soit correctement libéré il faut impérativement utiliser StrDispose() et non un simple FreeMem()(13) .

Dans tous les cas, n'oubliez pas la règle suivante :

Toute chaîne ou tout tampon alloué sur le tas doit être libéré explicitement après usage (14).

III-C - Manipulation de PAnsiChars

Évoquons maintenant les opérations que peut effectuer une fonction recevant un PAnsiChar (comme on l'a dit précédemment, généralement suivi par une taille de tampon dans la majorité des cas). Travailler sur le PAnsiChar n'est pas extrêmement pratique puisque l'on retombe dans l'optique du C où on doit utiliser des fonctions utilitaires pour effectuer des opérations sur des chaînes. Voici quelques routines de manipulation utiles :

Opération

Fonctions

Comparaison sensible à la casse

StrCmp

Concaténation

StrCat, StrLCat……

Mise en majuscules / minuscule

StrUpper, StrLower……

Copie

StrCpy, StrLCopy, StrPLCopy……

Longueur de la chaîne

StrLen (ne compte pas le #0 final)

Une remarque importante : dans la liste des fonctions ci-dessus, vous remarquerez que plusieurs fonctions portent quasiment le même nom, à l'exception d'un « L ». Les fonctions comportant ce « L » (StrPLCopy(), StrLCat()…) demandent toujours en argument la taille maximum qui a été allouée à la chaîne pour éviter d'écrire au-delà si le résultat dépassait la taille initialement allouée. Je vous recommande vivement de n'utiliser que ces fonctions ! Se passer de cette sécurité, c'est prendre le risque de lever des violations d'accès et de corrompre la mémoire de votre programme en cas de « chaîne trop longue ». C'est aussi en ne contrôlant pas ce paramètre que votre code devient vulnérable aux attaques par « débordement de tampon », bien connus des programmeurs C. En effet, sans protections, il devient possible de modifier intentionnellement certaines valeurs de la mémoire du programme, ce qu'il vaut mieux dès le départ empêcher d'autoriser !

Je vous invite à consulter l'aide Delphi au chapitre « routines de gestion des chaînes (à zéro terminal) » pour découvrir la liste complète des procédures de gestion des chaînes AZT.

Imaginons une fonction dans une DLL devant renvoyer une valeur quelconque. Il suffira d'utiliser StrPLCopy() pour effectuer la copie en gérant le nombre maximal de caractères pouvant être accueillis par la chaîne :

procedure foo(ADest: Pchar; ACount: Integer);
begin
StrPLCopy(ADest, 'Use the source, Luke', ACount);
//Place dans ADest une chaîne quelconque.
end;

Il est bien sûr possible de manipuler directement des PChar comme on le ferait en C. Une variable p déclarée PChar et pointant vers une chaîne quelconque peut être accédée comme un tableau. p[n] correspond alors au caractère d'index n de la chaîne. Lorsque n vaut zéro, alors p[n] correspond au premier caractère de la chaîne. L'exemple suivant montre une fonction comptant le nombre d'occurrences du caractère passé en paramètre de la fonction :

function TForm1.CountChars(AString: PChar; AChar: Char): Integer;
var
I: Integer;
begin
Result := 0;
if AString = nil then Exit; //Pointeur nul: on sort.
I := 0;
while not (AString[I] = #0) do //Jusqu'à la fin
begin
if AString[I] = AChar then
Inc(Result);
Inc(I);
end;
end;

Autrement dit, on se déplace dans le tableau jusqu'à atteindre le caractère de fin de chaîne grâce à la variable d'index i. Parallèlement, on compare chaque caractère au caractère passé en paramètre et s'ils sont égaux, on incrémente le résultat.

Il est également possible de jouer avec l'arithmétique des pointeurs comme on le ferait en C? Plutôt que d'écrire P[n], la notation (P + N)^ est en effet également valide. L'addition permet d'obtenir l'adresse du caractère, et l'opérateur de dé référencement (^) le caractère en lui-même. Sur un point de vue conceptuel, l'adresse est évaluée comme P + N * SizeOf(Char) ce qui correspond à un déplacement d'octet en octet (puisque SizeOf(Char) = 1). Ainsi, si le premier caractère d'une chaîne (d'index zéro) S est situé à l'adresse $00007771, (S+7)^ correspondra à son huitième caractère d'adresse $00007779.

L'exemple suivant montre un parcours de chaîne utilisant cette technique :

function TForm1.StringLen(AString: PChar): Integer;
begin
Result := 0;
if AString = nil then Exit;
while true do
begin
if (AString + Result)^ = #0 then
Exit
else
Inc(Result);
end;
end;

Autrement dit, Result sert ici d'index et est incrémenté jusqu'à que le caractère #0 soit rencontré. À ce moment-là, il contient la longueur de la chaîne.

Une remarque sur les index des chaînes. Comme on l'a dit plus haut, les chaînes C « commencent » à l'index zéro, autrement dit, le premier caractère d'une chaîne s s'accède par s[0]. En effet, le langage C est très axé sur les pointeurs, et comme on l'a dit, une chaîne n'est qu'un pointeur vers une zone mémoire. Par conséquent, s[i] est automatiquement traduit en (S + I)^ ou, en C, *(S+I), cela fait que S[0] correspond à S^, c'est-à-dire le premier caractère de la chaîne. En Pascal par contre, pour des raisons historiques, le premier caractère se situe à l'index 1. C'est une autre philosophie ! Par conséquent, selon que vous codez des algorithmes qui parcourent des chaînes, retenez ceci :

Si vous travaillez avec des chaînes C de longueur n, parcourez-les de 0 à n-1.

Si vous travaillez avec des chaînes Delphi de longueur n, parcourez-les de 1 à n.

Il doit être normalement inutile de repréciser ce qu'il ne faut pas faire avec les PChars, à savoir les manipuler alors qu'ils ne sont pas initialisés (qu'ils ne pointent sur rien). Voilà un exemple de ce qu'il ne faudrait pas faire :

procedure TForm1.Button4Click(Sender: TObject);
var
P: PChar;
begin
1. P := 'Hello';
2. P[1] := 'd';
3. StrPCopy(P, 'World);
end;

La première ligne tente d'assigner une chaîne à un pointeur. Vous savez maintenant que ce n'est pas valide. La seconde donne la valeur « d » au second caractère de la chaîne, ce qui est parfaitement incohérent puisqu’encore une fois celle-ci n'est pas allouée. La troisième est dans le même style puisqu'elle utilise StrPCopy sur la chaîne non initialisée.

III-D - Allocations intermodules

Encore quelques mots sur le passage de chaînes entre applications et librairies. Vous remarquerez que lorsqu'un module doit modifier ou simplement renvoyer une chaîne, l'allocation et la libération de mémoire sont toujours faites du même côté. Il aurait pu être envisagé de passer en paramètre un pointeur sur un PChar (un PPChar !). Du côté de la fonction devant renvoyer la chaîne, il aurait fallu allouer le tampon, copier la chaîne, et faire pointer le pointeur reçu vers le tampon. Du côté de l'application exploitante, une fois la chaîne récupérée, on aurait pu enfin libérer le tampon. En plus de n'être pas très cohérent (qui dit que l'utilisateur n'oubliera pas de libérer le tampon ?), cette manière de faire n'est pas possible, car FreeMem ne fonctionne pas à travers des modules différents (15). De plus, la manière de gérer la mémoire étant différente dans chaque langage, qui dit qu'un utilisateur de Visual Basic pourrait exploiter correctement la fonction ?

En résulte la règle suivante :

Lorsque des chaînes de type PAnsiChar doivent être renvoyées ou modifiées par des librairies, l'allocation mémoire doit être faite côté application. La librairie ne doit que recevoir un pointeur sur un tampon, éventuellement sa taille, et travailler dessus.

Voici un exemple qui montre une très simple fonction renvoyant une chaîne depuis une DLL :

Côté librairie :

procedure GetAppDirectory(ADir: PChar; ACount: Integer); stdcall;
var
LDir: string;
begin
LDir := { Fonction renvoyant un String }
StrPLCopy(ADir, LDir, ACount);
end;

Côté application :

procedure FooBar;
var
Lbuffer: array [0..512] of char; //On suppose que le dossier ne peut dépasser 512 caractères
begin
GetAppDirectory(LBuffer, SizeOf(LBuffer)); //Ou directement 512 à la place du SizeOf().
//On peut utiliser LBuffer comme un string ou un tableau...
if not (LBuffer = '') then
with TFileStream.Create(IncludeTrailingBackSlash(LBuffer) + 'test.dat', fmCreate) do
//... code
end;

IV - Conclusion

Comme vous avez pu le voir, les types String et PChars servent le même usage, mais imposent des manières de travailler très différentes. Avec le premier type, quasiment toutes les opérations sont réalisées automatiquement par Delphi, alors que le second implique que vous fassiez tout par vous-même.

À moins que vous n'en ayez expressément besoin, évitez d'utiliser des PChars quand vous n'en avez pas besoin, à moins que vous sachiez parfaitement ce que vous faites. Le type string est bien plus évident et évite de nombreuses erreurs dues à l'allocation mémoire automatique. Lorsque vous avez besoin d'utiliser des chaînes C, faites-le au dernier moment, et utilisez au maximum des chaînes Delphi classiques !

(1)Attention, il s'agit bien du caractère de code ASCII 0 (#0), et non du nombre zéro (dont le code ASCII est en l'occurrence 48)

(2)Les chaînes C sont parfois appelées « chaînes AZT » pour « à zéro terminal ». La notation hongroise préconisée par Microsoft fait de plus apparaître abondamment le préfixe « psz » pour ces chaînes, signifiant ici « pointeur sur chaîne (string) terminée par un zéro ».

(3)Bien sûr, il ne s'agit que d'un exemple d'implémentation.

(4)Bien que forcément, en interne et automatiquement, Delphi utilise des mécanismes similaires pour traiter le type string.

(5)La copie étant effectuée au moment de la modification et pas avant, ce mécanisme est parfois appelé en Anglais « copy on write ».

(6)Array déclare un tableau de données en Pascal. Si vous ne maîtrisez pas les tableaux, je vous conseille de vous référer à l'aide Delphi.

(7)L'inverse est automatique. Lorsqu'un PAnsiChar valide est assigné à un AnsiString, la chaîne est automatiquement modifiée pour refléter la chaîne pointée par le PChar.

(8)Attention, ne confondez pas le type PAnsiChar/PChar et l'opérateur de conversion PChar(). Les premiers désignent un type, le second renvoie un pointeur à partir d'une chaîne AnsiString.

(9)La taille de la pile d'exécution est définie dans les options du projet (Alt+F11).

(10)Pile (stack) et Tas (heap) sont deux zones dans lesquels les allocations mémoires peuvent être faites. Les variables locales sont automatiquement allouées sur la pile alors que les allocations explicites (GetMem()?) et les objets le sont sur le tas. Reportez-vous à l'aide Delphi pour plus de détails.

(11)GetMemory() et FreeMemory() pourront être remplacés respectivement par GetMem() ou FreeMem(), cela n'a pas d'importance.

(12)StrNew() appelle en interne StrAlloc().

(13)En effet, StrDispose() va se positionner « avant » le compteur de longueur pour libérer la chaîne, chose que ne fait évidemment pas FreeMem().

(14)Sinon bonjour les fuites mémoires !

(15)En effet, la DLL et l'application ne partagent pas le même tas.