Simplifier le passage de listes en paramètres et leur création

Les développeurs de sites web ayant gouté à jQuery et des frameworks récents en Javascript vous le diront : une fois passé le choc de la syntaxe utilisée il est vraiment pratique de pouvoir enchainer les commandes les unes après les autres.

$('input#CopyChild').click(function() {
	var currentNodeHTML = $('div#mainNode').html()
	var childText = $('div#mainNode').children('div').text();
    $('div#mainNode').html(childText + currentNodeHTML);
})

Cette façon de procéder a une certaine élégance et permet d'éviter de déclarer des variables qui ne seraient utilisées que pour appeler un, deux, trois appels de méthodes avant d'être désallouées. En programmation Javascript on passerait notre temps à faire ça.

Il n'y a pas de mystère dans ce fonctionnement : toute méthode jQuery retourne un objet jQuery et a par conséquent la possibilité d'être chainée avec une autre méthode.

Rien ne nous empêche de faire la même chose en Object Pascal, mais rien non plus n'incite les concepteurs de librairies ou classes à fournir ce qu'il faut pour le faire. Ce n'est pas encore totalement entré dans les moeurs.

J'y vois surtout un intérêt lorsqu'on désire créer une classe à partir de données existantes ou en dur pour un usage unique. La création suivie des commandes de remplissage de la classe peuvent parfois être très fastidieuses.

Je vais vous proposer aujourd'hui 3 exemples pratiques avec une liste de chaines, un tableau JSON et un objet JSON. Et vous verrez que Delphi n'est pas à la traine, loin de là :-)

Les listes de chaines de caractères

Dans cet exemple comme les suivants je pars d'une fiche vierge à laquelle j'ajoute des boutons et un mémo. Les boutons sont en alignement tAlignLayout.Top. Le champs de texte occupe le reste de la fiche. Chaque bouton va permettre de montrer une façon de créer la liste des chaines passées ensuite à la même fonction qui se contentera d'afficher le contenu de la liste sur le mémo. Je parle de même fonction mais elle sera bien entendu adaptée dans chaque cas au type d'éléments qu'elle doit gérer.

procedure TForm1.ajout_lignes(lignes: tstrings; supprimer: boolean = true);
var
  ch: string;
begin
  if assigned(lignes) then
  begin
    if (lignes.Count > 0) then
      for ch in lignes do
        Memo1.Lines.Add(ch);
    if supprimer then
      lignes.Free;
  end;
end;

Cette fonction prend une liste de chaines héritée de TStrings (qui est une classe abstraite et donc inutilisable en l'état) et un booléen permettant de désallouer la mémoire utilisée uniquement pour ce passage de paramètres. J'y mets True par défaut car dans mon exemple tous les objets créés sont à usage unique et doivent donc être supprimés après l'ajout ou après l'appel de la méthode ajout_lignes() faisant cet ajout.

Vous connaissez forcément cette façon de créer une liste de chaines : déclaration d'une variable de type TStringList, création d'une instance de cet objet, ajout des lignes une par une, puis appel de la fonction d'affichage. C'est un classique, il n'y a rien à en dire.

procedure TForm1.Button1Click(Sender: TObject);
var
  lignes: TStringList;
begin
  lignes := TStringList.Create;
  lignes.Add('ligne 1');
  lignes.Add('ligne 2');
  lignes.Add('ligne 3');
  lignes.Add('ligne 4');
  lignes.Add('ligne 5');
  ajout_lignes(lignes);
end;

Notez quand même que comme je libère la mémoire de la liste dans la méthode ajout_lignes(), toute utilisation de la variable lignes après cet appel provoquera une exception. La variable n'est pas mise à nil mais pointe sur un espace mémoire non attribué.

Comme cela fait beaucoup de lignes pour juste créer une liste et que je préfère avoir une syntaxe plus courte, j'aimerais écrire un truc du style

Create().Add().Add().Add()

Le hic c'est que la méthode Add() de la classe TStringList ne retourne pas une TStringList mais un entier. On ne peut donc pas écrire ça comme ça.

Sous Delphi nous avons donc deux possibilités pour contourner cet épineux problème : surcharger la classe TStringList ou créer un Helper.

Pour surcharger la classe on commence donc par créer un nouveau type TMyStringList.

type
  TMyStringList = class(TStringList)
    function ajoute(ch: string): TStringList;
  end;

Ce type hérite des propriétés et méthodes de son ancêtre. Il peut être utilisé dans toute affectation vers une variable du type de l'un de ses ancètres et donc en paramètre à notre méthode ajout_lignes().

function TMyStringList.ajoute(ch: string): TStringList;
begin
  Add(ch);
  Result := Self;
end;

On ne lui ajoute qu'une méthode que j'ai tout bonnement appelée ajoute() et qui retourne l'instance de la classe pour laquelle elle est appelée. Cela permet donc d'enchainer les appels.

procedure TForm1.Button3Click(Sender: TObject);
begin
  ajout_lignes(TMyStringList.Create.ajoute('ligne 1').ajoute('ligne 2')
    .ajoute('ligne 3').ajoute('ligne 4').ajoute('ligne 5'));
end;

Ne trouvez-vous pas cela plus pratique à écrire ?

Il est cependant très important de penser à supprimer l'espace mémoire alloué, d'où la présence du paramètre "supprimer" dans la méthode ajout_lignes() et de l'appel à Free() à l'intérieur de celle-ci.

Dans un modèle de gestion mémoire de style ARC, utilisé par Delphi en compilation mobile et envisagé par Embarcadero pour Windows, cette désallocation mémoire n'est pas nécessaire. Le garbage collector sachant très bien gérer la situation.

La surcharge par héritage fonctionne normalement dans tous les cas, c'est donc la solution que je vous suggère. Cependant je voulais aussi vous montrer une autre solution même si elle a une contrainte gênante.

Delphi propose d'ajouter des méthodes à des classes existantes sans les modifier ni les hériter. Cette technique s'appelle les Helpers.

Le problème, c'est que pour le moment on ne peut avoir qu'une classe de Helpers par classe. C'est la dernière déclarée qui est prise en compte.
Il est fort à parier que cette restriction disparaisse lor d'une prochaine version de Delphi et RAD Studio, plusieurs demandes de développeurs allant dans ce sens sur le bugtracker public.

Cette technique bien pratique peut donc générer des problèmes dans des cas où vous créez une classe Helpers pour une classe qui en a déjà une et dont vous utilisez les méthodes ajoutées. Elles deviendraient inutilisables.

Ceci dit, un Helper est quand même sympa à utiliser. Alors voici comment procéder.

Pour faire un Helper pour une classe donnée, il faut créer un nouveau type de classe.

type
  TMyStringListHelper = class helper for TStringList
    function ajoute(ch: string): TStringList;
  end;

On ne spécifie aucune notion d'héritage dans cette classe en revanche on signale qu'elle vient compléter la classe d'origine. Dans notre cas la TStringList.

La méthode ajoute() a la même implémentation que celle de la classe héritée. Le compilateur fusionne les méthodes du helper avec sa classe complétée et par conséquent tout est transparent pour le développeur.

Grâce à cette façon de faire on peut donc transformer notre appel à l'affichage de cette manière :

procedure TForm1.Button2Click(Sender: TObject);
begin
  ajout_lignes(TStringList.Create.ajoute('ligne 1').ajoute('ligne 2')
    .ajoute('ligne 3').ajoute('ligne 4').ajoute('ligne 5'));
end;

On utilise directement un TStringList auquel a été ajoutée la méthode ajoute() par l'intermédiaire de son helper.

Embarcadero s'en sert notamment pour les nombreuses fonctions de traitement des chaines de caractères, les ToString, ToInt, ToXXX sur les types scalaires (Integer, Boolean, ...) et on retrouve des Helpers un peu partout dans la RTL, la VCL et FMX.

Les tableaux JSON

De plus en plus je me sers de JSON pour stocker des informations sous Delphi. La gestion d'une arborescence sans avoir à déclarer le type de ses éléments au préalable est très pratique. En revanche l'utilisation des classes de création est fastidieuse et nécessite de passer régulièrement par un transtypage d'un TJSONValue (contrainte nécessaire liée à la grande souplesse de travail proposée par cette librairie JSON).
Bien entendu il vaut mieux savoir ce que l'on fait avant et avoir une doc à jour de cette arborescence pour ne pas improviser et permettre de maintenir le code.

Il y a des cas où j'ai besoin de créer des tableaux à la main, par exemple pour générer des listes de choix possibles sur des cases à cocher.

Dans ce cas je me sers de la même technique et ce qui est bien avec les versions récentes de la librairie JSON de Delphi, c'est qu'il est possible de chainer certaines méthodes comme on le désire.

Ce genre de choses :

procedure TForm2.Button1Click(Sender: TObject);
var
  jsa: TJSONArray;
begin
  jsa := TJSONArray.Create;
  jsa.Add('ligne 1');
  jsa.Add('ligne 2');
  jsa.Add('ligne 3');
  jsa.Add('ligne 4');
  jsa.Add('ligne 5');
  ajout_lignes(jsa);
end;

peut ainsi se simplifier sans "tricher" à surcharger les types :

procedure TForm2.Button2Click(Sender: TObject);
begin
  ajout_lignes(TJSONArray.Create.Add('ligne 1').Add('ligne 2').Add('ligne 3')
    .Add('ligne 4').Add('ligne 5'));
end;

C'est quand même plus pratique, non ?

Les objets JSON

Et du coup après les tableaux c'est au tour des objets qui permettent également ce genre de manipulations lors de la création d'un TJSONObject et de l'ajout de paires clé/valeur.

On a tendance à écrire ça :

procedure TForm3.Button1Click(Sender: TObject);
var
  jso: TJSONObject;
begin
  jso := TJSONObject.Create;
  jso.AddPair('ligne 1', 'texte 1');
  jso.AddPair('ligne 2', 'texte 2');
  jso.AddPair('ligne 3', 'texte 3');
  jso.AddPair('ligne 4', 'texte 4');
  jso.AddPair('ligne 5', 'texte 5');
  ajout_lignes(jso);
end;

mais on peut aussi le formuler de cette façon :

procedure TForm3.Button2Click(Sender: TObject);
begin
  ajout_lignes(TJSONObject.Create.AddPair('ligne 1', 'texte 1')
    .AddPair('ligne 2', 'texte 2').AddPair('ligne 3', 'texte 3')
    .AddPair('ligne 4', 'texte 4').AddPair('ligne 5', 'texte 5'));
end;

Exemple d'utilisation d'un cas réel

Peut-être vous demandez-vous dans quelles situations on a réellement besoin de ce genre de choses. Parce qu'admettons-le il n'est pas courant de se faire des listes de valeurs en dur dans un programme pour lequel on s'efforce au maximum de tout rendre paramétrable et donc issu de bases de données ou de fichiers de configuration.

En fait vous vous en servez peut-être déjà tous les jours en enchainant des Helpers sur TString afin de simplifier votre code :

procedure dummy;
var
  ch: string;
begin
  ch := Edit1.Text.Trim.ToLower;
  if (ch.Length > 0) then
    // traitement
  else
    showmessage('erreur');
end;

J'en ai eu besoin sur un projet de framework gérant une API pour des applications mobiles.

Le serveur est développé en PHP. La partie cliente est développée sous Delphi.

Je l'ai découpée en deux unités côté Delphi : l'une de base qui gère l'accès POST et GET via http ou https, l'autre qui gère les paramètres et les formats d'appels de l'API.

Avec cette déclaration de mes classes de bas niveau :

type
  TOSAPICallbackProc = reference to procedure(response: tjsonobject);

  TOSAPIParams = class(TDictionary<string, string>)
    function addParam(key, value: string): TOSAPIParams;
  end;

  TOSAPIParam = TPair<string, string>;

  TOSAPIFiles = class(TStrings)
    function addFile(filepath: string): TOSAPIFiles;
  end;

  TOSAPI = class(TObject)
    class procedure get(http_user_agent, api_url, api_resource: string;
      _GET: TOSAPIParams; callback: TOSAPICallbackProc);
    class procedure post(http_user_agent, api_url, api_resource: string;
      _POST: TOSAPIParams; _FILES: TOSAPIFiles; callback: TOSAPICallbackProc);
  end;

  TOSAPIErrorResponse = class(TObject)
    class function get(error_code: integer; error_text: string): tjsonobject;
  end;

je peux ainsi simplifier l'appel en une seule instruction plutôt que construire pléthore d'objets et les passer ensuite :

    TOSAPI.post(FUserAgent, Fapp_url, 'user', TOSAPIParams.create()
      .addParam('user_token', user_token).addParam('session_token',
      session_token).addParam('user_email', user_email)
      .addParam('user_firstname', user_firstname).addParam('user_lastname',
      user_lastname).addParam('verif',
      ChecksumVerif.get(TChecksumVerifParamList.create.addParam(user_token)
      .addParam(session_token).addParam(user_email).addParam(user_firstname)
      .addParam(user_lastname), user_secret, session_secret)), nil,
      procedure(response: tjsonobject)
      var
        error_code: integer;
        error_message: string;
        verif: string;
      begin
        if assigned(response) then
        begin
          try
            try
              error_code := (response.GetValue('error_code')
                as TJSONNumber).AsInt;
            except
              error_code := 999;
            end;
            if (error_code <> 0) then
            begin
              error_message := (response.GetValue('error_text')
                as TJSONString).Value;
              if assigned(proc_error) then
                proc_error(error_code, error_message);
            end
            else if assigned(proc_ok) then
              proc_ok;
          except
            if assigned(proc_error) then
              proc_error(998, response.ToJSON);
          end;
        end
        else if assigned(proc_error) then
          proc_error(998, 'no response from server');
      end);

Cette simplification des appels rend les choses plus faciles à écrire en tant que développeur mais aussi à maintenir car je vois tout de suite que je passe les bonnes informations, dans le bon ordre et qu'elles sont bien renseignées.

La classe TOSAPI gère les accès réseau, l'appel des URL et le formatage des réponses.
La classe TOSLoginMobileAPI quant à elle gère la liste des API en déclarant une méthode pour chaque API avec en paramètre les valeurs à lui passer. Elle traite ensuite les retours sous forme de procedures anynomes: une en cas de succès, l'autre en cas d'erreur.

Voici mon API de base:

  TOSLoginMobileAPI = class
  private
    Fapp_secret: string;
    Fapp_token: string;
    Fapp_url: string;
    FisAPIInitialized: boolean;
    FUserAgent: string;
    procedure Setapp_secret(const Value: string);
    procedure Setapp_token(const Value: string);
    procedure Setapp_url(const Value: string);
    procedure SetUserAgent(const Value: string);
  public
    property app_token: string read Fapp_token write Setapp_token;
    property app_secret: string read Fapp_secret write Setapp_secret;
    property app_url: string read Fapp_url write Setapp_url;
    property isAPIInitialized: boolean read FisAPIInitialized;
    property UserAgent: string read FUserAgent write SetUserAgent;
    constructor create(app_token, app_secret, app_url: string);
    procedure getDevice(proc_ok: TOSLoginMobileAPIGetDeviceOkProc;
      proc_error: TOSLoginMobileAPIErrorProc);
    procedure setSession(email, device_token, device_secret: string;
      proc_ok: TOSLoginMobileAPISetSessionOkProc;
      proc_error: TOSLoginMobileAPIErrorProc);
    procedure getSession(session_token, session_secret: string;
      proc_ok: TOSLoginMobileAPIGetSessionOkProc;
      proc_error: TOSLoginMobileAPIErrorProc);
    procedure deleteSession(session_token, session_secret: string;
      proc_ok: TOSLoginMobileAPIDeleteSessionOkProc;
      proc_error: TOSLoginMobileAPIErrorProc);
    procedure getUser(session_token, session_secret, user_token,
      user_secret: string; proc_ok: TOSLoginMobileAPIGetUserOkProc;
      proc_error: TOSLoginMobileAPIErrorProc);
    procedure setUser(session_token, session_secret, user_token, user_secret,
      user_email, user_firstname, user_lastname: string;
      proc_ok: TOSLoginMobileAPISetUserOkProc;
      proc_error: TOSLoginMobileAPIErrorProc);
  end;

Le programme devant utiliser l'API n'a qu'à stocker les informations dont il a besoin et appeler les méthodes de TOSLoginMobileAPI quand c'est nécessaire à son fonctionnement. Bien entendu tous les accès réseaux sont asynchrones pour ne pas bloquer l'application et les procedures de callback sont synchronisées avec le thread principal donnant ainsi accès aux composants visuels de l'interface.

procedure TForm1.btnGetDeviceIDClick(Sender: TObject);
begin
  tParams.setValue('app_token', edtAppToken.Text);
  tParams.setValue('app_secret', edtAppSecret.Text);
  tParams.setValue('app_url', edtURLAPI.Text);
  oslmapi.app_token := tParams.getValue('app_token', '');
  oslmapi.app_secret := tParams.getValue('app_secret', '');
  oslmapi.app_url := tParams.getValue('app_url', '');
  oslmapi.getDevice(
    procedure(device_token, device_secret: string)
    begin
      tParams.setValue('device_token', device_token);
      tParams.setValue('device_secret', device_secret);
      affiche_parametres;
      edtEmail.SetFocus;
    end,
    procedure(error_code: integer; error_text: string)
    begin
      Memo1.Lines.Add('ko (' + error_code.ToString + ') - ' + error_text);
      edtAppToken.SetFocus;
    end);
end;

Je reviendrai sur le projet osLogin lorsqu'il sera bouclé et publié.

Conclusion

L'Object Pascal est à la fois un langage structurant, typé et strict, mais il sait aussi proposer de la souplesse à qui en veut. Cette grande liberté de codage implique aussi que les développeurs doivent rester attentifs à ce qu'ils font. Les pertes de mémoire ou les accès à des espaces non alloués peuvent apparaitre bien après la distribution des logiciels et sont toujours compliqués à identifier. Autant éviter les plus évidents.

Dans vos développements pensez à l'usage qui sera fait de vos classes et composants. Tentez de simplifier au maximum le travail des développeurs et leur laisser la souplesse des habitudes que nous prenons dans ce monde multiplateformes où nous avons tous plusieurs langages de développement en tête et des habitudes de codages très personnelles.

Ces projets exemples sont comme les autres disponibles sur GitHub, n'hésitez pas à les y télécharger.


A lire aussi

Jouons un peu avec les types énumérés et leurs valeurs (05/12/2017)
Simplifier le passage de listes en paramètres et leur création (24/07/2017)

Membre du programme MVP.
Membre du programme MVP