POO à GOGO : les enregistrements avec Free Pascal et Lazarus (1/2)

Enregistrements simples et compactage

Gilles Vasseur

Ce tutoriel propose une étude des enregistrements en Free Pascal applicable à Lazarus. Après quelques définitions, il s'agira d'examiner les enregistrements simples et leur compactage, prélude obligé à la découverte des enregistrements étendus introduits par la Programmation Orientée Objet et de leurs différentes facettes.

Commentez

- Titre : POO à GOGO : les enregistrements avec Free Pascal et Lazarus (1/2)

- Auteur : Gilles Vasseur

- Parution : 28 mars 2017

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2017 Gilles Vasseur. 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. Introduction

- II. Les enregistrements traditionnels

- II-A. Déclarer un enregistrement traditionnel

- II-A-1. La déclaration simple

- II-A-2. L'imbrication d'enregistrements

- II-A-3. Les parties avec variantes

- II-B. L'accès aux champs

- II-B-1. La notation avec le point

- II-B-2. De l'utilisation de with

- II-B-3. Les enregistrements en tant que constantes typées

- II-B-4. Les problèmes d'affectation

- II-C. Les limites des enregistrements

- II-D. Deux exemples simples d'utilisation

- II-D-1. Illustration des affectations

- II-D-2. Illustration des enregistrements constantes typées et des parties variantes

- III. Le compactage des enregistrements

- III-A. De la taille d'un enregistrement

- III-A-1. Taille d'un enregistrement

- III-A-2. Vitesse d'exécution et compactage

- III-B. De l'utilité du compactage

- III-C. Les règles du compactage

- III-C-1. Compacter avec bitpacked

- III-C-2. Compacter avec packed

- IV. Conclusion

I - Introduction

Les programmes de test sont présents dans le répertoire exemples accompagnant le présent document.

Contrairement aux tableaux (array) et aux ensembles (set) qui proposent des structures pour des données homogènes, un enregistrement (record) est une structure fixe qui regroupe des données de différents types sous un même nom.

Avec les enregistrements, on pourra ainsi réunir un numéro, un nom de voie, un code postal et un nom de ville dans un enregistrement qui constituera une adresse. Comme tous les autres types, le nouveau type défini sera utilisable pour déclarer des variables.

Bien que souvent employés pour des fichiers, les enregistrements ne sont pas liés intrinsèquement à eux : il est tout à fait possible de définir un enregistrement qui regrouperait, par exemple, la partie réelle et la partie imaginaire d'un nombre complexe, hors de toute notion de fichier. De la même manière, le graphisme fait souvent appel à des enregistrements représentant des points définis par leurs coordonnées dans un repère orthonormé, sans qu'il soit là encore question de fichier.

Chaque donnée qui apparaît dans un enregistrement est appelée champ. Dans l'enregistrement d'une adresse, le code postal ou le nom de la ville sont des exemples de champs. De même, la partie imaginaire est un champ d'un enregistrement représentant un nombre complexe tandis que l'abscisse est un champ d'un enregistrement représentant les coordonnées d'un point.

II - Les enregistrements traditionnels

On appelle enregistrements traditionnels ou simples les enregistrements qui ne comprennent que des champs. En ce sens, ils s'opposent aux enregistrements étendus qui seront étudiés ultérieurement.

II-A - Déclarer un enregistrement traditionnel

II-A-1 - La déclaration simple

Un enregistrement étant un type a priori inconnu du compilateur, il est nécessaire de le déclarer. On utilise à cette fin le mot réservé record.

Un enregistrement correspondant à une adresse postale pourrait prendre la forme suivante :

type
TAddress = record
Number: Integer;
Street: string;
ZipCode: Integer;
Town: string;
end;

var
AnAddress, AnotherAddress: TAddress;

Le type déclaré est TAddress. Grâce à cette déclaration, il devient possible de déclarer des variables de ce type : AnAddress et AnotherAddress par exemple. À leur tour, ces variables permettront de travailler avec des parties des adresses grâce aux champs définis.

La déclaration d'un type enregistrement n'alloue aucune mémoire, que ce soit pour la structure ou pour les champs.

Les types des champs d'un enregistrement sont quelconques. Comme toujours en Free Pascal, ils doivent cependant être déclarés avant toute utilisation, ce qu'il est possible de faire à l'intérieur même de la structure :

type
TMyDate = record
Day: 1..31;
Month: (Janvier, Fevrier, Mars, Avril, Mai, Juin, Juillet, Aout,
Septembre, Octobre, Novembre, Decembre);
Year: 1900..2100;
end;

On voit dans l'exemple ci-dessus que l'intervalle Day et l'énumération Month ont été déclarés à la volée au sein de l'enregistrement TMyDate.

Les types déclarés à l'intérieur d'une structure d'enregistrement ne sont accessibles que via cette structure. De plus, ils ne peuvent être définis sous la forme d'une déclaration suivie d'une implémentation.

Il est même possible de définir un enregistrement en déclarant une variable :

var
MyDate: record
Day: 1..31;
Month: (Janvier, Fevrier, Mars, Avril, Mai, Juin, Juillet, Aout,
Septembre, Octobre, Novembre, Decembre);
Year: 1900..2100;
end;

La variable MyDate est ici correctement définie. Cependant, une telle déclaration n'est pratiquement jamais rencontrée : d'une part, la variable définie ne sera compatible avec aucune autre variable, quand bien même les deux auraient la même structure ; d'autre part, elle annule l'intérêt principal des enregistrements, à savoir la non-répétition de la saisie de structures répétitives !

II-A-2 - L'imbrication d'enregistrements

Comme un enregistrement comprend n'importe quel type de champs, il est possible de définir un champ qui sera lui-même un enregistrement (en dehors de lui-même, bien sûr).

On peut imaginer un type d'enregistrement qui correspondrait à l'identité d'une personne et qui comprendrait une partie adresse postale. La déclaration d'un tel type prendrait la forme suivante :

type
TAddress = record
Number: Integer;
Street: string;
ZipCode: Integer;
Town: string;
end;

TUser = record
FirstName: string;
SurName: string;
Address: TAddress;
end;

var
Users: array[1..10] of TUser;

On voit que le type TAddress est inclus dans le type TUser et qu'ils déclarent tous les deux une nouvelle variété d'enregistrement. Une variable Users regroupe dans un tableau un ensemble de dix enregistrements de type TUser.

Il est même possible de déclarer un type d'enregistrement à l'intérieur d'un autre :

TUser = record
FirstName: string;
SurName: string;
Address: record
Number: Integer;
Street: string;
ZipCode: Integer;
Town: string;
end;
end;

Cette déclaration est équivalente à celles utilisées ci-avant, à ceci près que le type TAddress n'existe plus en tant que tel.

S'il n'y a pas de limite au niveau d'imbrication des enregistrements, une grande complexité nuit à la lisibilité du code. Il est par conséquent rare d'avoir à déclarer un type d'enregistrement renfermant plus d'un niveau d'imbrication.

II-A-3 - Les parties avec variantes

Il arrive parfois qu'une partie de l'enregistrement dépende d'une condition particulière. Par exemple, on peut vouloir distinguer les parcours des élèves d'une filière donnée par des renseignements différents suivant les options qu'ils auront choisies. Free Pascal offre cette possibilité grâce aux parties avec variantes.

La structure revêt alors une forme particulière, proche de celle d'un case ordinaire :

type
TProfile = (proProgramming, proNetWorks);
TNote = 0..20;

TStudentResults = record
Algorihms: TNote;
English: TNote;
French: TNote;
Maths: TNote;
// …
case Profile: TProfile of
proProgramming: (FreePascal: TNote; Java: TNote);
proNetWorks: (NetWorks: TNote);
end;

L'enregistrement TStudentResults comprend une partie commune aux deux profils définis par le type TProfile. Sa déclaration se termine toutefois par une partie avec variantes : si le profil est proProgramming, il existe deux champs actifs (FreePascal et Java) alors qu'un seul champ est actif si le profil est proNetWorks (NetWorks).

Cette construction appelle plusieurs remarques :

- le type qui permet de distinguer les variantes doit être un ordinal (c'est-à-dire ni une chaîne, ni un nombre réel, ni un Int64 ou un qword) ;

- les types qui, comme les chaînes longues et les objets, doivent être initialisés avant d'être employés (que ce soit automatiquement ou non) ne peuvent pas paraître dans la partie variante ;

- l'identificateur de choix (Profile dans l'exemple) est appelé discriminant ou sélecteur et constitue un champ à part entière ;

- le discriminant est facultatif : on l'omet lorsqu'il n'est pas utilisé dans le code ;

- les enregistrements avec variantes peuvent être imbriqués.

La partie avec variantes est toujours celle qui clôt la déclaration d'un enregistrement : la faire apparaître avant les champs communs déclenchera une erreur de compilation.

Voici une portion de code similaire à celui proposé précédemment, mais sans le discriminant :

type
TNote = 0..20;

TStudentResults = record
Algorihms: TNote;
English: TNote;
French: TNote;
Maths: TNote;
// …
case Boolean of
True: (FreePascal: TNote; Java: TNote);
False: (NetWorks: TNote);
end;

Le type TProfile a été supprimé pour utiliser un booléen : suivant les valeurs entrées, le compilateur choisira automatiquement la bonne option parmi les deux proposées.

Accessoirement, les enregistrements avec variantes permettent de transtyper à peu de frais des variables suivant la variante sollicitée. L'exemple de points utilisés pour dessiner des rectangles illustre cette dernière possibilité :

type
TPoint = record
X: LongInt;
Y: LongInt;
end;
TRect = record
case Integer of
0: (Left,Top,Right,Bottom: Longint);
1: (TopLeft,BottomRight: TPoint);
end;

Dans ces définitions, TopLeft coïncidera avec les valeurs attribuées à Left et Top tandis que BottomRight correspondra à celles de Right et Bottom. Ainsi, si l'on donne une valeur aux champs Left et Top, il sera possible de récupérer directement la valeur de TopLeft.

Aucune vérification n'est faite par le compilateur lors du transtypage qui est par conséquent sous la responsabilité entière du programmeur qui devra s'assurer que les champs à mettre en relation correspondent bien.

II-B - L'accès aux champs

II-B-1 - La notation avec le point

À présent que nous savons définir des enregistrements, il est évidemment indispensable de pouvoir accéder aux informations qu'ils renferment. En fait, rien de plus simple : il suffit de faire suivre le nom de la variable de l'enregistrement d'un point et d'y adjoindre le nom du champ désiré.

Par exemple, à partir de l'enregistrement TStudentResults, on obtiendra :

var
Student1: TStudentResults;
[…]

Student1.English := 12;
Student1.Maths := 7;
Student1.French := 14;
Students.NetWorks := 18;

Les données n'ont pas à être entrées dans l'ordre de déclaration de l'enregistrement, même si cette contrainte peut éviter d'oublier certaines d'entre elles. Il n'est pas plus indispensable de fournir toutes les données requises par l'enregistrement si elles ne sont pas utilisées, même si, là encore, une mauvaise initialisation d'une variable locale (comme n'importe quelle autre variable locale) peut avoir des conséquences imprévisibles.

Comme vu plus haut, le transtypage est automatique, mais non contrôlé pour les champs avec variantes. On pourra donc écrire :

type
TPoint = record
X: LongInt;
Y: LongInt;
end;

TRect = record
case Integer of
0: (Left,Top,Right,Bottom: Longint);
1: (TopLeft,BottomRight: TPoint);
end;

var
MyRect: TRect;
[…]

MyRect.Top := 15;
MyRect.Left := 32;
MyRect.Bottom := 125;
MyRect.Right := 232;

Memo1.Lines.Add(Format('Largeur : %d', [MyRect.BottomRight.X - MyRect.TopLeft.X]));

On a tout intérêt à bien nommer les champs : un des avantages des enregistrements est qu'ils offrent une lecture facile du code, à condition bien évidemment que l'intitulé de leurs champs soit parlant !

Lorsque l'enregistrement comprend des enregistrements imbriqués, l'accès aux sous-champs s'effectue par des points supplémentaires pour rendre compte de l'arborescence. Ainsi, on obtiendra :

type
TUser = record
FirstName: string;
SurName: string;
Address: record
Number: Integer;
Street: string;
ZipCode: Integer;
Town: string;
end;
end;

var
User1, User2: TUser;

[…]

User1.Address.Number:= 45
User1.Address.Town:= 'Paris';
User2.FirstName:= 'Blaise';
User2.Address.ZipCode:= 78000;

II-B-2 - De l'utilisation de with

Une construction particulière avec le mot réservé with est possible afin d'éviter les saisies répétitives. Au lieu de l'écriture précédente, on peut simplifier la saisie ainsi :

var
Student1: TStudentResults;
[…]

with Student1 do
begin
English := 12;
Maths := 7;
French := 14;
NetWorks := 18;
end;

L'instruction with est capable de faire le départ entre les différentes sources des données. Ainsi, des constructions comme les suivantes sont autorisées :

type
TMyRec = record
Data1: Integer;
Data2: Integer;
Data3: Boolean;
end;

TMySecondRec = record
Zone: Integer;
Checked: Boolean;
end;

[…]

var
MyRec: TMyRecord;
MySecondRec: TMySecondRec;

[…]

with MyRec, MySecondRec do
begin
Data1 := 45;
Data2 := Zone;
Data3 := Checked;
end;

//ou même...
with MyRec, MySecondRec do
begin
Data1 := 45;
Data2 := Zone;
Checked := Data3;
end;

Le dernier exemple montre que les affectations n'ont même pas à être unilatérales. Il faudra cependant faire attention à maintenir un minimum de lisibilité, surtout pour des structures complexes : si le compilateur ne se perd pas dans l'imbroglio des champs et des affectations, il risque de ne pas en être de même pour le programmeur !

En cas d'ambiguïté, le compilateur la signalera :

type
TMyRec = record
Data1: Integer;
Data2: Integer;
Data3: Boolean;
end;

TMySecondRec = record
Data1: Integer;
Zone: Integer;
Checked: Boolean;
end;

[…]

var
MyRec: TMyRecord;
MySecondRec: TMySecondRec;

[…]

with MyRec, MySecondRec do
begin
Data1 := 45; / / erreur !
Data2 := Zone;
Data3 := Checked;
end;

Dans ce dernier cas, le compilateur ne peut pas décider de quel Data il s'agit si bien qu'il s'arrête en déclenchant une erreur.

Dans le cas particulier de champs homonymes, il reste toujours à craindre que des erreurs surviennent et qu'elles soient difficiles à détecter. Qu'on pense à des champs baptisés X, Y, Left, Right, Top... Comment reconnaître sans erreur à quoi ils appartiennent au milieu d'un code touffu ?

De nombreux programmeurs proscrivent totalement cette écriture avec with, surtout dans des équipes où plusieurs intervenants sont susceptibles de partager certaines portions de code. Les mêmes font aussi valoir que l'éditeur de Lazarus propose automatiquement les champs possibles après le point, évitant ainsi une bonne partie de la frappe et des potentielles erreurs qui lui sont liées.

II-B-3 - Les enregistrements en tant que constantes typées

Un enregistrement peut revêtir la forme d'une constante typée. Pour cela, il suffit de spécifier la valeur constante de tous les champs, dans l'ordre de leurs déclarations :

type
TAddress = record
Number: Integer;
Street: string;
ZipCode: Integer;
Town: string;
end;

TUser = record
FirstName: string;
SurName: string;
Address: TAddress;
end;

const
User1: TUser = (FirstName: 'Blaise'; SurName: 'Pascal';
Address.Number: 12;
Address.Street: 'rue de Port-Royal';
Address.ZipCode: 78000);

Le nom de chaque champ est séparé de la valeur affectée par le signe « : ». Chaque affectation est séparée de la suivante par un point-virgule et le tout est placé entre parenthèses.

Si l'enregistrement comporte une partie avec variantes, seule la variante sélectionnée peut contenir des valeurs assignées.

II-B-4 - Les problèmes d'affectation

L'affectation ne pose en général pas de problème avec les enregistrements. Il suffit en effet d'affecter une variable d'un type donné à une autre variable du même type :

type
TAddress = record
Number: Integer;
Street: string;
ZipCode: Integer;
Town: string;
end;

var
MyAddress, YourAddress: TAddress;

[…]

MyAddress.Number:= 12;
MyAddress.Street:= 'rue de la Paix';
MyAddress.ZipCode:= 75016;
MyAddress.Town:= 'Paris';

YourAdress:= MyAddress;

Dans l'exemple donné, la variable YourAddress voit tous ses champs affectés selon ceux de la variable MyAddress.

Les difficultés commencent si l'on essaye d'affecter des variables d'un type donné avec un type qui ne sera pas exactement similaire. Autrement dit, deux types déclarés séparément, même s'ils contiennent les mêmes champs, ne sont pas compatibles directement pour l'affectation.

type
TAddress = record
Number: Integer;
Street: string;
ZipCode: Integer;
Town: string;
end;

TAddress2 = record
Number: Integer;
Street: string;
ZipCode: Integer;
Town: string;
end;

var
MyAddress: TAddress;
YourAddress: TAddress2;

[…]

MyAddress.Number:= 12;
MyAddress.Street:= 'rue de la Paix';
MyAddress.ZipCode:= 75016;
MyAddress.Town:= 'Paris';

YourAddress:= MyAddress; // erreur de compilation !

Pour affecter au premier enregistrement les valeurs contenues dans le second, on procédera alors champ par champ et sans utiliser with, bien évidemment. Une autre possibilité, beaucoup plus compacte, mais sans autre vérification du compilateur que celle de la taille des types en jeu, consistera à transtyper le second enregistrement pour le forcer à correspondre au premier :

YourAddress:= TAddress(MyAddress);

II-C - Les limites des enregistrements

Par définition, les enregistrements comprennent des champs dont le format est déterminé dès leur déclaration et sont de longueur fixe. Ils ne sont donc pas adaptés au stockage de données de longueurs variables. Généralement, on fera appel dans ce dernier cas à des technologies plus souples comme les fichiers XML ou Json.

Une autre limite est plus difficile à saisir par le débutant : les pointeurs ne sont pas utilisables dans des enregistrements si ces derniers doivent être sauvegardés. En effet, un pointeur renvoie à une adresse en mémoire qui n'est valable que lors de la session d'exécution de l'application. C'est cette adresse qui sera sauvegardée et non les données sur lesquelles il pointe ! Lors d'une exécution ultérieure, récupérer cette adresse depuis un enregistrement sauvegardé n'aurait plus aucun sens et conduirait très probablement à des violations d'accès et au plantage de l'application.

De la même manière, des types comme les chaînes longues ou les objets, qui cachent un dispositif d'allocation et de libération de la mémoire, ne peuvent être utilisés que dans le cadre restreint de l'instance lors de laquelle ils auront été créés. Là encore, la sauvegarde de tels enregistrements stockerait des pointeurs sans signification lors d'une exécution ultérieure.

Dans les exemples choisis jusqu'à présent, vous aurez sans doute remarqué que seules des chaînes longues avaient été employées : c'est la situation de développement la plus courante. Puisqu'elles n'étaient pas stockées sur un support, leur utilisation ne posait pas de problèmes particuliers. Bien plus, le traitement des caractères UTF-8 était correct, ce qui n'aurait pas été le cas avec des chaînes courtes. En revanche, la sauvegarde aurait été problématique, les enregistrements ne stockant que les adresses pointant vers les chaînes de caractères elles-mêmes.

II-D - Deux exemples simples d'utilisation

Afin de rendre plus concrètes les notions abordées jusqu'à présent, il peut être utile d'écrire quelques exemples élémentaires.

II-D-1 - Illustration des affectations

[Exemple record01]

Le premier exemple a pour objectif essentiel d'illustrer les affectations.

La structure d'enregistrement utilisée est très simple :

type
TMyRecord = record
FirstName: string;
Surname: string;
Sexe: Boolean;
Age: Word;
end;

L'interface utilisateur prendra cette apparence:

On remarque des zones de saisie (deux TEdit pour le prénom et le nom, une TCheckBox pour le sexe, un TSpinEdit pour l'âge), cinq boutons pour exécuter les actions voulues et un TMemo pour l'affichage des résultats. Le fichier LFM renseigne exactement sur l'interface ainsi créée :

object MainForm: TMainForm
Left = 347
Height = 403
Top = 146
Width = 606
Caption = 'Test des enregistrements (record) 01'
ClientHeight = 403
ClientWidth = 606
Position = poScreenCenter
LCLVersion = '1.6.2.0'
object mmoMain: TMemo
Left = 324
Height = 383
Top = 10
Width = 272
Align = alRight
BorderSpacing.Around = 10
ScrollBars = ssAutoBoth
TabOrder = 0
end
object gbData: TGroupBox
Left = 10
Height = 383
Top = 10
Width = 305
Align = alLeft
BorderSpacing.Around = 10
Caption = 'Données'
ClientHeight = 363
ClientWidth = 301
TabOrder = 1
object lblFirstName: TLabel
Left = 13
Height = 15
Top = 8
Width = 48
BorderSpacing.Around = 10
Caption = 'Prénom :'
FocusControl = edtFirstName
ParentColor = False
end
object edtFirstName: TEdit
Left = 72
Height = 23
Top = 0
Width = 216
BorderSpacing.Around = 10
TabOrder = 0
Text = 'Blaise'
end
object lblSurname: TLabel
Left = 13
Height = 15
Top = 40
Width = 33
BorderSpacing.Around = 10
Caption = 'Nom :'
FocusControl = edtSurname
ParentColor = False
end
object edtSurname: TEdit
Left = 72
Height = 23
Top = 35
Width = 216
BorderSpacing.Around = 10
TabOrder = 1
Text = 'Pascal'
end
object cbSexe: TCheckBox
Left = 13
Height = 19
Top = 72
Width = 68
BorderSpacing.Around = 10
Caption = 'Femme ?'
TabOrder = 2
end
object seAge: TSpinEdit
Left = 72
Height = 23
Top = 101
Width = 50
BorderSpacing.Around = 10
MaxValue = 150
TabOrder = 3
Value = 20
end
object lblAge: TLabel
Left = 13
Height = 15
Top = 109
Width = 27
BorderSpacing.Around = 10
Caption = 'Age :'
FocusControl = seAge
ParentColor = False
end
object btnClear: TButton
Left = 10
Height = 25
Top = 150
Width = 75
BorderSpacing.Around = 10
Caption = 'Nettoyer'
OnClick = btnClearClick
TabOrder = 4
end
object btnNew: TButton
Left = 104
Height = 25
Top = 150
Width = 75
Caption = 'Nouveau'
OnClick = btnNewClick
TabOrder = 5
end
object btnNewWith: TButton
Left = 200
Height = 25
Top = 150
Width = 75
Caption = 'Avec With'
OnClick = btnNewWithClick
TabOrder = 6
end
object btn2With: TButton
Left = 10
Height = 25
Top = 192
Width = 75
Caption = 'Avec 2 With'
OnClick = btn2WithClick
TabOrder = 7
end
object btnDirect: TButton
Left = 10
Height = 25
Top = 232
Width = 75
Caption = 'Direct'
OnClick = btnDirectClick
TabOrder = 8
end
end
end

Le bouton intitulé « Nettoyer » permet de remettre à zéro le contenu du TMemo :

procedure TMainForm.btnClearClick(Sender: TObject);
begin
mmoMain.Lines.Clear;
end;

Le bouton intitulé « Nouveau » crée un nouvel enregistrement en remplissant les champs à partir des données entrées par l'utilisateur :

procedure TMainForm.btnNewClick(Sender: TObject);
begin
// on récupère les données depuis les contrôles adéquats
MyRecord.FirstName := edtFirstName.Text;
MyRecord.Surname := edtSurname.Text;
MyRecord.Age := seAge.Value;
MyRecord.Sexe := cbSexe.Checked;
// on ajoute les données au mémo
mmoMain.Lines.Add(MyRecord.FirstName);
mmoMain.Lines.Add(MyRecord.Surname);
mmoMain.Lines.Add('Age : ' + IntToStr(MyRecord.Age));
if MyRecord.Sexe then
mmoMain.Lines.Add('Sexe féminin')
else
mmoMain.Lines.Add('Sexe masculin');
mmoMain.Lines.Add('');
end;

Le bouton intitulé « Avec With » effectue le même travail que le précédent, mais en utilisant le raccourci with. L'écriture en est plus condensée, mais au risque de confusions possibles :

procedure TMainForm.btnNewWithClick(Sender: TObject);
begin
with MyRecord do
begin
FirstName := edtFirstName.Text;
Surname := edtSurname.Text;
Age := seAge.Value;
Sexe := cbSexe.Checked;
mmoMain.Lines.Add(FirstName);
mmoMain.Lines.Add(Surname);
mmoMain.Lines.Add('Age : ' + IntToStr(Age));
if Sexe then
mmoMain.Lines.Add('Sexe féminin')
else
mmoMain.Lines.Add('Sexe masculin');
end;
mmoMain.Lines.Add('');
end;

Le quatrième bouton est intitulé « Avec 2 With ». Même si le compilateur effectue correctement son travail, la lisibilité décroît encore avec la combinaison de deux with dont l'un se rapporte à l'objet mmoMain de type TMemo afin d'accéder à ses méthodes :

procedure TMainForm.btn2WithClick(Sender: TObject);
begin
with  MyRecord, mmoMain.Lines do
begin
FirstName := edtFirstName.Text;
Surname := edtSurname.Text;
Age := seAge.Value;
Sexe := cbSexe.Checked;
Add(FirstName);
Add(Surname);
Add('Age : ' + IntToStr(Age));
if Sexe then
Add('Sexe féminin')
else
Add('Sexe masculin');
Add('');
end;
end;

Le dernier bouton, intitulé « Direct », montre qu'il est possible d'affecter directement un enregistrement à un autre s'ils sont du même type. La première partie de la méthode n'utilise pas les with imbriqués, de manière à lever toute ambiguïté. La seconde est plus condensée, mais le code est exposé à des confusions possibles :

procedure TMainForm.btnDirectClick(Sender: TObject);
var
MyRecord2: TMyRecord;
begin
MyRecord.FirstName := edtFirstName.Text;
MyRecord.Surname := edtSurname.Text;
MyRecord.Age := seAge.Value;
MyRecord.Sexe := cbSexe.Checked;
mmoMain.Lines.Add(MyRecord.FirstName);
mmoMain.Lines.Add(MyRecord.Surname);
mmoMain.Lines.Add('Age : ' + IntToStr(MyRecord.Age));
if MyRecord.Sexe then
mmoMain.Lines.Add('Sexe féminin')
else
mmoMain.Lines.Add('Sexe masculin');
mmoMain.Lines.Add('');

MyRecord2 := MyRecord;

with MyRecord2, mmoMain.Lines do
begin
Add(FirstName);
Add(Surname);
Add('Age : ' + IntToStr(Age));
if Sexe then
Add('Sexe féminin')
else
Add('Sexe masculin');
Add('');
end;
end;

L'exécution de l'application donnera ce type d'affichage :

II-D-2 - Illustration des enregistrements constantes typées et des parties variantes

[Exemple record02]

Le second exemple met en œuvre des enregistrements avec des parties variantes déclarées sous forme de constantes typées.

L'interface utilisateur est très simple puisqu'elle ne comporte que deux TButton et un TMemo. Voici une capture d'écran correspondant au projet :

Le fichier LFM sera alors celui-ci :

object MainForm: TMainForm
Left = 442
Height = 463
Top = 310
Width = 623
Caption = 'Test des enregistrements (record) 02'
ClientHeight = 463
ClientWidth = 623
Position = poScreenCenter
LCLVersion = '1.6.4.0'
object mmoMain: TMemo
Left = 288
Height = 418
Top = 16
Width = 304
ScrollBars = ssAutoBoth
TabOrder = 0
end
object btnTypedConstant: TButton
Left = 24
Height = 25
Top = 32
Width = 240
Caption = 'Constante typée (avec discriminant)'
OnClick = btnTypedConstantClick
TabOrder = 1
end
object btnTypedConstant2: TButton
Left = 24
Height = 25
Top = 72
Width = 240
Caption = 'Constante typée (sans discriminant)'
OnClick = btnTypedConstant2Click
TabOrder = 2
end
end

Le code de l'application est lui aussi simple du point de vue de l'écriture, mais il va poser quelques problèmes à l'occasion de modifications minimes.

Dans un premier temps, il faut déclarer des structures de travail :

type
TProfile = (proProgramming, proNetWorks);
TNote = 0..20;

TStudentResults = record
Algorihms: TNote;
English: TNote;
French: TNote;
Maths: TNote;
case Profile: TProfile of
proProgramming: (FreePascal: TNote; Java: TNote);
proNetWorks: (NetWorks: TNote);
end;

TStudentResults2 = record
Algorihms: TNote;
English: TNote;
French: TNote;
Maths: TNote;
// …
case Boolean of
True: (FreePascal: TNote; Java: TNote);
False: (NetWorks: TNote);
end;

Le type enregistrement TStudentResults utilise le discriminant Profile contrairement à TStudentResults2 qui s'en passe. Chacun des boutons procédera au test d'un des deux types déclarés.

Le premier bouton btnTypedConstant utilise le discriminant :

procedure TMainForm.btnTypedConstantClick(Sender: TObject);
// *** constante typée avec discriminant ***
const
LStudent: TStudentResults = (Algorihms: 12;
English: 9;
French: 8;
Maths: 11;
Profile: proProgramming;
FreePascal: 15;
Java: 13);
begin
mmoMain.Lines.Add('');
case LStudent.Profile of
proProgramming: mmoMain.Lines.Add('Etudiant (programmation)');
proNetWorks: mmoMain.Lines.Add('Impossible !');
end;
mmoMain.Lines.Add('Algorithmes : ' + IntToStr(LStudent.Algorihms));
mmoMain.Lines.Add('Anglais : ' + IntToStr(LStudent.English));
mmoMain.Lines.Add('Français : ' + IntToStr(LStudent.French));
mmoMain.Lines.Add('Maths : ' + IntToStr(LStudent.Maths));
mmoMain.Lines.Add('Free Pascal : ' + IntToStr(LStudent.FreePascal));
mmoMain.Lines.Add('Java : ' + IntToStr(LStudent.Java));
end;

Non seulement les valeurs peuvent être affectées aux champs, mais il est aussi possible de retrouver l'information correspondant au profil de l'étudiant via le discriminant.

Ce n'est pas ce qu'il advient avec la forme sans discriminant gérée par le second bouton btnTypesConstant2 :

procedure TMainForm.btnTypedConstant2Click(Sender: TObject);
// *** constante typée avec discriminant ***
const
LStudent: TStudentResults2 = (Algorihms: 12;
English: 9;
French: 8;
Maths: 11;
FreePascal: 15;
Java: 13);
begin
mmoMain.Lines.Add('');
mmoMain.Lines.Add('Étudiant (programmation ou réseaux ?)');
mmoMain.Lines.Add('Algorithmes : ' + IntToStr(LStudent.Algorihms));
mmoMain.Lines.Add('Anglais : ' + IntToStr(LStudent.English));
mmoMain.Lines.Add('Français : ' + IntToStr(LStudent.French));
mmoMain.Lines.Add('Maths : ' + IntToStr(LStudent.Maths));
mmoMain.Lines.Add('Free Pascal : ' + IntToStr(LStudent.FreePascal));
mmoMain.Lines.Add('Java : ' + IntToStr(LStudent.Java));
end;

On remarque en effet qu'il est impossible dans ce cas de déterminer le type d'étudiant dont il s'agit. Dans ce cas précis, il aurait été préférable de déclarer un discriminant explicite.

Mais le plus important n'est pas là : diverses manipulations de ce code vont provoquer des problèmes dont le programmeur a tout intérêt à tenir compte !

En premier lieu, ajouter une simple ligne à la fin des méthodes précédentes montre que les champs occultés par le code (ici, le champ NetWorks) restent accessibles avec ou sans discriminant :

// !!! Mauvais aiguillage, mais tâche effectuée
mmoMain.Lines.Add('Réseaux : ' + IntToStr(LStudent.NetWorks) + ' ???');

Dans tous les cas, le champ NetWorks prend la valeur qui lui correspond dans l'enregistrement une fois l'emplacement mémoire transtypé si nécessaire (ici, ce n'est pas la peine puisque le champ FreePascal est aussi de type Integer). On obtiendra par conséquent cet affichage, sans aucun avertissement du compilateur :

Il faut retenir que les champs relatifs à des parties variantes et les discriminants qui s'y rapportent restent sous l'entière responsabilité du programmeur.

D'autres comportements peuvent même paraître étranges. Il suffit de modifier ainsi les constantes typées pour s'en rendre compte :

const
LStudent: TStudentResults = (Algorihms: 12;
English: 9;
French: 8;
Maths: 11;
Profile: proProgramming;
FreePascal: 15;
Java: 13;
NetWorks: 4);

Tout se passe alors comme si la valeur du champ NetWorks était ignorée : l'écran d'exécution obtenu est strictement le même que précédemment ! Le pire est qu'il en est de même si l'on change la valeur du discriminant Profile pour le mettre à proNetWorks…

La souplesse que procurent les enregistrements, en particulier lors de l'utilisation de constantes typées, est source de pièges potentiels dont il faut tenir compte avec la plus grande prudence.

En revanche, toujours dans le cas des constantes typées, le compilateur n'appréciera pas du tout qu'un champ quelconque soit renseigné avant un autre. Pour le vérifier, il faut par exemple commenter le champ FreePascal ou inverser les champs French et Maths : on obtient alors une erreur lors de la compilation.

III - Le compactage des enregistrements

La compréhension des mécanismes mis en œuvre lors de l'utilisation des enregistrements est importante pour qui veut les utiliser au mieux, en particulier si des programmes doivent échanger des informations ou si différentes plates-formes sont en jeu. Les problèmes essentiels résident dans la taille des enregistrements qui dépend elle-même de l'alignement des champs en leur sein.

III-A - De la taille d'un enregistrement

III-A-1 - Taille d'un enregistrement

Par définition, les enregistrements ont une taille fixe. Par conséquent, quelles que soient les valeurs affectées aux champs d'un enregistrement donné, l'utilisateur a l'assurance que chaque enregistrement sera similaire à un autre du point de vue de sa taille. En revanche, rien n'assure qu'un type enregistrement aura la même taille d'une plate-forme à une autre. De même, l'ordre de déclaration des champs a son importance, la taille pouvant être modifiée par le simple déplacement de l'un d'entre eux.

Sans autre précision concernant la structure d'un enregistrement, il ne faut jamais supposer un emplacement particulier en mémoire pour un champ donné. Si le compilateur range toujours les champs dans l'ordre indiqué par le code source, il optimise l'espace occupé.

[Exemple record03]

Le premier problème est par conséquent celui de l'espace occupé en mémoire par un enregistrement. Afin de mettre en évidence cette difficulté, nous allons écrire une petite application qui ne comprendra que trois TButton accompagnés de trois TLabel placés comme suit :

Grâce à la fonction SizeOf, chaque bouton calculera la taille des enregistrements tels que définis ci-après :

type

TSimpleRecord = record
ADouble: Double;
AnInteger: Integer;
ABoolean: Boolean;
AnotherBoolean: Boolean;
end;

TSimpleRecord2 = record
AnotherBoolean: Boolean;
ADouble: Double;
AnInteger: Integer;
ABoolean: Boolean;
end;

TPackedRecord = packed record
ADouble: Double;
AnInteger: Integer;
AnotherBoolean: Boolean;
ABoolean: Boolean;
end;

La seule nouveauté est l'emploi pour le dernier type du mot réservé packed qui demande au compilateur de compacter l'enregistrement concerné. Avant d'expliquer son fonctionnement, nous allons voir l'ensemble en action.

Le code est vraiment très simple puisqu'il ne fait que réagir aux clics sur les boutons pour afficher dans l'étiquette appropriée la valeur de la taille de l'enregistrement visé :

{ TMainForm }

TMainForm = class(TForm)
btnSimpleRecord: TButton;
btnPackedRecord: TButton;
btnSimpleRecord2: TButton;
lblSimpleRecord2: TLabel;
lblSimpleRecord: TLabel;
lblPackedRecord: TLabel;
procedure btnPackedRecordClick(Sender: TObject);
procedure btnSimpleRecord2Click(Sender: TObject);
procedure btnSimpleRecordClick(Sender: TObject);
private
{ private declarations }
public
{ public declarations }
end;

var
MainForm: TMainForm;

implementation

{$R *.lfm}

{ TMainForm }

procedure TMainForm.btnSimpleRecordClick(Sender: TObject);
begin
lblSimpleRecord.Caption := IntToStr(sizeof(TSimpleRecord));
end;

procedure TMainForm.btnPackedRecordClick(Sender: TObject);
begin
lblPackedRecord.Caption := IntToStr(sizeof(TPackedRecord));
end;

procedure TMainForm.btnSimpleRecord2Click(Sender: TObject);
begin
lblSimpleRecord2.Caption := IntToStr(sizeof(TSimpleRecord2));
end;

end.

Les valeurs attendues pourraient être calculées manuellement. Sachant qu'un booléen occupe un octet, qu'un entier en occupe quatre et qu'un nombre flottant en double précision en occupe huit, une addition montre qu'il faut normalement quatorze octets pour composer ces enregistrements.

L'exécution de ce programme rudimentaire crée cependant d'importantes surprises.

Seul l'enregistrement compacté occupe la place escomptée. En fait, le mot réservé packed permet l'alignement de tous les champs sur un début d'octet sans créer de vides entre eux : le total de l'espace occupé est par conséquent la somme des longueurs de tous les champs calculées en octets.

Le premier type (TSimpleRecord) occupe deux octets de trop : en commentant tour à tour les autres champs, on s'aperçoit que le compilateur a choisi d'aligner les champs sur huit octets, soit la taille du plus grand des champs. On obtient donc huit octets pour le Double et huit octets pour stocker l'Integer et les deux Boolean, soit seize octets.

De la même manière, la taille démesurée du second type d'enregistrement (TSimpleRecord2) s'explique si l'on commente les autres champs ou si l'on remplace le champ ADouble par un champ de type Integer, par exemple. L'alignement se fait toujours en fonction du plus grand des champs : huit octets pour le Boolean isolé, huit octets pour le Double, huit octets pour l'Integer rangé avec le dernier Boolean, soit vingt-quatre octets.

Il ressort de ces différentes manipulations qu'il est fortement conseillé, en dehors de directives de compactage, de placer les champs les plus courts à la fin de la déclaration de l'enregistrement.

[Exemple record03bis]

Il est possible de remplacer packed par bitpacked: comme son nom l'indique, bitpacked va demander l'alignement des champs non plus sur le prochain octet libre, mais sur le prochain élément binaire libre (bit).

En modifiant ainsi la déclaration de l'enregistrement, on obtiendra une taille de treize octets dans l'application exemple :

TPackedRecord = bitpacked record // unique modification
ADouble: Double;
AnInteger: Integer;
AnotherBoolean: Boolean;
ABoolean: Boolean;
end;

Après ajout d'un TButton et d'un TLabel pour le nouveau type, ainsi que la méthode OnClick associée, le compilateur parvient à grignoter un peu de place de telle façon qu'un octet ne soit plus nécessaire à l'enregistrement :

Une dernière information peut être utile : celle de la place occupée par un enregistrement comprenant une partie avec variantes. Cette dernière commence par le champ sélecteur s'il a été renseigné par un identificateur, suivi de la place requise par la plus grande des variantes.

III-A-2 - Vitesse d'exécution et compactage

Après ce qui vient d'être vu, on pourrait en conclure que les enregistrements ont toujours intérêt à être compactés, mais ce serait aller trop vite en besogne. En effet, comme souvent en informatique, un compromis existe entre l'espace mémoire occupé et la vitesse d'exécution : les processeurs sont plus rapides lorsqu'ils travaillent à des frontières natives. Ces dernières peuvent être des octets ou des groupements d'octets, généralement sur 32 ou 64 bits de nos jours. Les forcer à manipuler des données hors de ces frontières est possible, mais nécessite plus de travail et par conséquent plus de temps.

Comme les temps mesurés seraient trop imprécis en utilisant des instruments aussi grossiers que les timers, il paraît nécessaire de vérifier les temps d'accès aux enregistrements grâce à des outils plus perfectionnés comme des outils de profilage. On a utilisé ProLaza pour les résultats qui suivent, un outil commercial qui a montré depuis longtemps son efficacité.

[Exemple record04]

L'application testée affecte 100 000 fois les mêmes données aux champs d'enregistrements de types différents. On demande à ProLaza de déterminer le temps utilisé par chacune des procédures. Voici le moule de chacune de ces procédures, l'unique modification à apporter étant de changer le type d'enregistrement utilisé :

procedure TMainForm.ChangeSimpleRecord;
var
Li: Integer;
LRec: TSimpleRecord;
begin
for Li := 1 to 100000 do
begin
LRec.ABoolean := True;
LRec.ADouble := 12589748.012458;
LRec.AnInteger := 987654321;
LRec.AnotherBoolean := False;
end;
end;

Les résultats sont surtout probants pour bitpacked qui ralentit sérieusement l'exécution du code alors que packed ne se fait pas sentir, au moins pour ce type d'enregistrement :

La quatrième colonne indique le pourcentage d'occupation de la routine alors que la sixième exprime son temps d'exécution réel. Les résultats ont été obtenus en compilant le programme en 32 bits avec Lazarus 1.6.4 (donc sous Free Pascal 3.0.2) sur une machine Windows 10 Pro équipée d'un processeur i3 4130 3,4 Ghz avec 16 Go de mémoire vive.

III-B - De l'utilité du compactage

Le compactage des enregistrements apporte des bénéfices non négligeables :

- il économise l'espace occupé par les enregistrements, surtout pour de grandes séries de données ;

- il permet de communiquer avec d'autres langages de programmation qui n'auront pas les mêmes stratégies d'optimisation de la structure d'un enregistrement ;

- il est indispensable à la lecture et à l'écriture de fichiers binaires qui n'accepteront pas d'être réorganisés ;

- il autorise les transtypages en alignant correctement les champs pour qu'ils coïncident, mais sans vérifier la pertinence de ces transtypages.

En dehors d'éventuels problèmes de performances, le compactage n'a malheureusement pas toutes les vertus nécessaires pour une utilisation sans souci : les règles définies ci-après limitent parfois sérieusement les domaines de leur emploi !

III-C - Les règles du compactage

III-C-1 - Compacter avec bitpacked

Bitpacked n'aligne les champs sur les éléments binaires (bits) que dans la mesure où ils relèvent d'un type ordinal. La fonction BitSizeOf renvoie alors la taille en bits de l'enregistrement ou du champ passé en paramètre.

Voici le résultat obtenu avec l'exemple 03bis :

Dans tous les cas où le type des champs n'est pas un ordinal, l'alignement se fait sur un début d'octet. Il est aussi à noter que la taille d'un enregistrement est limitée par le système : 2^29 octets (soit 512 Mo) pour un système en 32 bits et 2^61 octets pour le 64 bits.

De plus, il est impossible avec ce mode de compactage de retrouver l'adresse d'un champ si sa longueur n'est pas un multiple de 8 et s'il n'est pas aligné sur une frontière d'octet, ce qui interdit de l'employer comme paramètre variable d'une procédure ou d'une fonction.

Enfin, non seulement l'implémentation dépend des plates-formes (en particulier à cause de l'endiannes des systèmes), mais elle est même susceptible d'être modifiée dans le futur : le programmeur doit considérer l'enregistrement créé comme une boîte noire.

III-C-2 - Compacter avec packed

Packed n'a pas la même signification suivant le contexte. Si la directive $BITPACKING est active, packed est l'équivalent de bitpacked tel qu'étudié précédemment. Dans le cas contraire, l'alignement d'un champ se fera toujours sur la frontière du premier octet libre dans l'enregistrement.

Pour les utilisateurs d'Apple, packed est toujours l'équivalent de bitpacked dans le mode MACPAS.

Le format du compactage peut être contrôlé grâce à la directive $PACKRECORDS dont la syntaxe générale est :

{$PACKRECORDS n}

Les valeurs prises par n peuvent être : 0, 1, 2, 4, 8, 16, 32, C ou DEFAULT.

Si la taille d'un champ est inférieure au nombre n, son alignement dans l'enregistrement se fera sur la puissance de 2 plus grande ou égale à cette taille. Dans le cas contraire, l'alignement se fera sur des débuts d'octets tous les n octets.

La valeur C pour le paramètre n correspond à l'alignement utilisé par le compilateur GNU C : elle est à utiliser dans des unités d'importation lors de l'utilisation de routines en C.

Les valeurs DEFAULT et 0 correspondent à l'alignement tel qu'il est pratiqué par le compilateur, hors de toute directive spéciale. Il est par conséquent dépendant de la plate-forme utilisée.

Pour un type enregistrement donné, l'unique façon d'être certain de pouvoir utiliser les données quelle que soit la plate-forme en jeu est d'utiliser la directive {$PACKRECORDS 1}.

L'alignement peut encore être spécifié par les directives spéciales suivantes :

{$A1} // équivaut à {$PACKRECORDS 1 }
{$A2} // équivaut à {$PACKRECORDS 2 }
{$A4} // équivaut à {$PACKRECORDS 4 }
{$A8} // équivaut à {$PACKRECORDS 8 }

Elles constituent de simples raccourcis, mais peuvent paraître moins explicites que la forme développée.

Il est aussi possible d'utiliser les directives $A ou $ALIGN qui sont des équivalents dès lors qu'un nombre les accompagne, mais qui ont d'autres significations pour les systèmes Apple et qui n'acceptent pas les autres valeurs de $PACKRECORDS.

Enfin, la directive $CODEALIGN comprend des paramètres qui autorisent le contrôle de l'alignement de champs suivant leur taille :

- RECORDMIN indique la taille minimale d'alignement ;

- RECORDMAX indique la taille maximale d'alignement.

Leur utilisation prendra, par exemple, la forme :

{$CODEALIGN RECORDMIN=2}
{$CODEALIGN RECORDMAX=4}

Ainsi, avec RECORDMIN, on pouvait fixer dans l'exemple 03bis une limite inférieure à l'alignement des champs :

{$CODEALIGN RECORDMIN=12}
TSimpleRecord2 = record // 24
AnotherBoolean: Boolean;
ADouble: Double;
AnInteger: Integer;
ABoolean: Boolean;
end;

À partir de ce moment, la taille affichée pour l'enregistrement aurait été de 40 et non de 24 !

En cas d'emploi successif de ces différentes directives, c'est la dernière rencontrée qui l'emporte.

IV - Conclusion

À l'aide de ce tutoriel, vous devriez être capable d'utiliser au mieux les enregistrements dans vos programmes. Vous aurez appris à :

- définir un type enregistrement simple avec ou sans variantes ;

- affecter des valeurs à des champs et les retrouver à la demande ;

- tenir compte des limites des enregistrements ;

- comprendre les mécanismes régissant le compactage afin de l'utiliser à bon escient.

Cet ensemble de connaissances va vous permettre à présent d'aborder les enregistrements étendus introduits par la Programmation Orientée Objet : ce sont des outils puissants, pour la plupart rencontrés à l'occasion de la manipulation d'objets, qui s'offrent ainsi aux programmeurs.

Merci à Alcatîz pour sa relecture technique et à Claude Leloup pour la correction orthographique.