Strings et PChars en Delphi
Date de publication : 09/08/2005
Par
Adrien Reboisson (Site perso)
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.
I. Généralités
A. Les chaînes Delphi
B. Les chaînes C
C. Quelques précisions sur le type string
D. Duplication de chaînes
II. Le type PAnsiChar et l'opérateur PChar()
A. Présentation et règles essentielles
B. A propos de l'allocation mémoire
C. DLL et PAnsiChars
III. Allocation et utilisation de chaînes C
A. Allocation sur la pile
B. Allocation sur le tas
C. Manipulation de PAnsiChars
D. Allocations inter-modules
IV. Conclusion
I. Généralités
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';
ShowMessage(S);
S := 'Bonjour, monde';
ShowMessage(S);
S := S + '!';
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.
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és par le langage comme en Delphi, les opérations habituelles
(assignation de texte, concaténation, extension…) doivent être programmés « à la main ». Par exemple, la concaténation d’une chaîne avec une autre (Chaine1 + Chaine2) va consister
(3)
:
- A se positionner sur le premier caractère de la première chaîne (la case du tableau d’index 0),
- A 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é à 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é pour
pouvoir recevoir la concaténation des deux objets. Par rapport au à 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é 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
toute les routines de gestion de chaîne « 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.
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écoule deux conséquences :
- Une chaîne Delphi peut stocker n’importe quelles données, même binaires, puisque 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 sors 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é. 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.
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 (comme 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
A := 'Hello World';
B :=A;
UniqueString(B);
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()
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érent
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ées.
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. A 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èterons 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
hors S n'existant plus, on cours à 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.
B. A 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.
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
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 erreur 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ées pour la chaîne (ici 1024).
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;
var
LFileName: PChar;
Begin
LFileName := GetMemory(cstMaxChars * SizeOf(Char));
GetModuleFileName(0, LFileName, cstMaxChars);
Result := LFileName;
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 tout 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).
C. Manipulation de PAnsiChars
Evoquons 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é à 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 renvoyée 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);
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;
I := 0;
while not (AString[I] = #0) do
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'arithemé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é. A 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 puisque 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.
D. Allocations inter-modules
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 est toujours fait 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érent 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 :=
StrPLCopy(ADir, LDir, ACount);
end;
Côté application :
procedure FooBar;
var
Lbuffer: array [0..512] of char;
begin
GetAppDirectory(LBuffer, SizeOf(LBuffer));
if not (LBuffer = '') then
with TFileStream.Create(IncludeTrailingBackSlash(LBuffer) + 'test.dat', fmCreate) do
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.
A 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 utilises 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.
|
|