La petite histoire de NULL en C++

Ce document se veut un petit récapitulatif du symbole NULL en C et en C++, dans le but d'expliquer pourquoi il est de mise de ne pas utiliser ce symbole, donc pourquoi il vaut mieux utiliser simplement 0 (ou mieux : nullptr, depuis C++ 11 ) à la place. Si vous souhaitez un tour d'horizon plus large de la question d'indirection nulle, voir ../Developpement/Null.html

On tend à privilégier en tout temps (ou presque) les constantes symboliques aux littéraux, et les langages comme Pascal (nil), le C traditionnel (NULL) tout comme les langages récents comme C# et Java (null) ou même VB.NET (Nothing) ont tous un symbole à part pour indiquer une référence ou un pointeur invalide.

Et pourtant, comme nous le verrons, ces craintes perdent leur sens en C++, et l'utilisation de NULL y est à proscrire (il en va différemment pour nullptr, de C++ 11, par contre).

Il y a bien longtemps...

Aux débuts du langage C, le concept de constante symbolique n'y existait pas (la situation a, heureusement, changé depuis).

On avait recours au préprocesseur pour simuler les constantes symboliques à l'aide de macros.

#define NELEVES 30
// le préprocesseur remplacera NELEVES par le
// littéral 30 avant la compilation
float notes[NELEVES];

À cette époque, NULL était donc une macro déclarée dans stdio.h (entre autres) en tant que pointeur de valeur 0 du type le plus abstrait alors disponible, soit le type char qui permettait d'adresser les bytes un à un.

#define NULL ((char*)0)

Toutes les apparitions de NULL dans le code étaient donc remplacées avant la compilation par le littéral typé ((char*)0). Le langage C faisant à la base une gestion très... complaisante des types, cela ne posait à peu près pas de problèmes.

Ceci peut sembler être un choix étrange, mais il faut comprendre que le langage C était un langage où l'unité de base est la fonction. Par défaut, toute fonction était de type int. Par défaut, tout paramètre était de type int. L'idée d'un sous-programme ne retournant rien n'y existait pas.

L'idée d'un pointeur abstrait (type void*) est venue beaucoup plus tard. Le type de base pour les opérations abstraites était le char*, qui permettait d'adresser chaque byte d'une séquence (car, en C comme en C++, sizeof(char)==1).

Les fonctions comme malloc(), qui alloue un bloc de mémoire dynamique d'une certaine taille, retournaient toutes des char*. Les fonctions comme memcpy(), qui opèrent sur de la mémoire brute, manipulaient toutes elles aussi des char*.

Une évolution, presque une révolution...

L'introduction en langage C d'un type de pointeur vraiment abstrait, le type void*, pour lequel le compilateur ne fait aucune présomption quant à la nature de l'objet pointé – et pour lequel il est obligatoire d'appliquer un transtypage (cast) avant utilisation – a amené la révolution suivante à la définition de la macro NULL.

#define NULL ((void*)0)

Il s'agit encore une fois d'une macro, dont chaque occurrence est remplacée dans le code avant la compilation, mais cette macro a le mérite d'être plus abstraite que la précédente. Encore une fois, le traitement plutôt... relaxe, disons-le ainsi, des types fait par un compilateur C a, en règle générale, beaucoup simplifié la migration du NULL précédent à celui-là .

Une évolution, un problème...

Le langage C++ est un langage offrant une support important pour une approche orientée objet (OO), et offre en retour une gestion beaucoup plus serrée des types – le support strict des types est promordial à toute approche OO.

Dans ce cas, utiliser NULL sous sa forme void* pose problème: déposer NULL dans un pointeur autre qu'un void* demande automatiquement une conversion explicite de types!

Le problème demeure entier peu importe le type de pointeur que représente NULL (si on utilise un char*, on aura besoin de le convertir pour l'utiliser avec un int*; si on utilise un int*, le problème sera le même pour un char*; etc.).

#define NULL ((void*)0)
// ...
// illégal: NULL et p ne sont pas de même type!
int  *p = NULL;
// légal
void *q = NULL;
// légal, mais seulement avec transtypage
int  *r = (int*) NULL;

Contrairement à l'intuition, utiliser une véritable constante (const T *NULL = 0;) n'améliore en rien la situation ici. Le rôle de NULL, qui est de nommer le lieu identifié à l'adresse 0 et garanti comme invalide peu importe la nature de ce qui est pointé, souffre encore de l'attribution d'un type, et presque tous les usages qui en sont faits deviennent assujettis à cette horreur de la nature qu'est la conversion explicite de types (le cast).

La clé (ou presque)

En fait, la seule chose qu'on puisse faire pour obtenir l'effet désiré, c'est à dire évoquer la seule adresse garantie comme étant invalide (adresse 0) sans égard au type de ce qui y est pointé, et d'utiliser 0, tout simplement.

// légal: p pointe sur l'entier (illégal) à  l'adresse 0!
int  *p = 0;
// légal: q est un pointeur abstrait sur un lieu illégal
void *q = 0;
// légal; transtypage superflu
int  *r = (int*) 0;

Les tests pour vérifier la validité d'un pointeur s'apparentent alors clairement à des tests booléens.

Notez que l'illustration proposée à droite montre un test sur p pour fins d'illustration, mais p ne serait pas nul suite à un appel à new puisqu'un échec de cet opérateur ne retournera pas un pointeur nul, levant plutôt une exception de type std::bad_alloc (défini dans <new>). Si vous souhaitez que new retourne un pointeur nul lors d'un échec, l'écriture correcte sera p = new (std::nothrow) int[10];.

int  *p = 0; // p est nul
// ...
p = new int[10];
// ...
// équivaut à  if (p!=0) ou à  if (p!=NULL)
if(p) {
   // utiliser p
}
// légal, même si !p (ne fait rien dans ce cas)
delete [] p;

Oui, mais si j'aime ça, moi, le symbole NULL

Les gens vraiment attachés au symbole NULL peuvent le définir s'ils en ont envie, mais seulement à l'aide de macros (car s'ils indiquent un type, alors ils seront forcés de lui appliquer régulièrement des transtypages).

// idée très, très moyenne (voir ci-dessous)
#define NULL 0

Mais C++ essaie de s'éloigner le plus possible du préprocesseur et des macros. Les templates permettent, en particulier, de remplacer presque toutes les applications de macros typiques (comme min() et max()), et les espaces nommés (namespace) font de même pour plusieurs applications des directives de compilation conditionnelle (les #ifndef et les #ifdef).

La raison pour laquelle on évite les macros est qu'avec elles, il devient possible de modifier le sens d'un programme alors qu'on le compile, et ce sans changer la moindre ligne de code. Par exemple, on peut ajouter à la compilation, à même la ligne de commande, l'équivalent de #define NULL 1 et modifier ainsi le sens de tous les modules compilés.

Les macros sont une chose très dangereuse. Il est sage de limiter leur utilisation au minimum. Soyez standards, et utilisez 0 (ou mieux, si vous avez un compilateur C++ 11 : utilisez nullptr). Votre code sera au moins aussi rapide, au moins aussi élégant, et n'en sera pas moins lisible.

Depuis C++ 11  nullptr

Si vous avez un compilateur C++ 11, alors l'idéal est d'utiliser le symbole nullptr, qui règles certains problèmes résiduels d'ambiguïté rencontrés à l'occasion avec le littéral 0 (et rapportés entre autres ici).

Un exemple simple illustrant ce problème avec C++ 03 va comme suit :

void f(char*);
void f(int);
int main() {
   f(0); // oups! ambigu!
}

Du fait qu'il est à la fois entier et implicitement convertible en pointeur, le littéral 0 mène à des appels de fonctions ambigus dans certaines circonstances. Notez que si cet exemple semble tiré par les cheveux, des situations analogues surviennent fréquemment dans un code générique et peuvent être plus complexes à dépister et à comprendre qu'il n'y paraît.

Le symbole nullptr est une instance du type std::nullptr_t, que vous trouverez dans <cstddef>. Le type std::nullptr_t est tout simplement decltype(nullptr), où decltype(exp) est un opérateur statique dont le fruit est le type d'une expression exp. Le symbole nullptr lui-même est un mot clé du langage C++ depuis C++ 11.

Ses caractéristiques sont simples :

Reprenant l'exemple plus haut, on constatera que l'ambiguïté avec 0 n'apparaît pas avec nullptr sous C++ 11 :

void f(char*);
void f(int);
int main() {
   f(0); // f(int)
   f(nullptr); // f(char*)
}

Puisque std::nullptr_t est un type à part entière, il est même possible de spécialiser les fonctions pour le cas d'un pointeur nul, du moins dans certaines circonstances. Cela ne couvre pas tous les cas, évidemment. Ainsi :

#include <cstddef>
void f(const char*);
void f(int);
void f(std::nullptr_t);
int main() {
   const char *p = "coucou!";
   const char *q = nullptr;
   f(0); // f(int)
   f(p); // f(const char*)
   f(nullptr); // f(std::nullptr_t)
   f(q); // f(const char*)... prudence!
}

Pour un exemple plus complet, examinons ce qui suit. Soit la classe tas_pointeurs<T> qui permet de regrouper plusieurs pointeurs sur des T en un même lieu, lui confier la responsabilité de libérer éventuellement les pointés, pour leur appliquer une fonction f de type F en bloc. Portez attention à la méthode ajouter() de cette classe (voir ici pour des explications sur Incopiable).

#include "Incopiable.h"
#include <vector>
#include <algorithm>
#include <ostream>
template <class T>
   class tas_pointeurs : Incopiable {
      std::vector<T*> v;
   public:
      void ajouter(T *p) {
         if (p)
            v.push_back(p);
      }
      //
      // ici, grà¢ce à  ajouter(), on n'a pas à  valider
      // chaque pointeur p car ils sont tous non-nuls
      //
      template <class F>
         void appliquer(F f) {
            for(auto p : v)
               f(p);
         }
      ~tas_pointeurs()  {
         for(auto p : v)
            delete p;
      }
   };
#include <iostream>
int main() {
   using namespace std;
   tas_pointeurs<int> tp;
   tp.ajouter(nullptr);
   for(int i = 0; i < 10; ++i)
      tp.ajouter(new int{i});
   tp.ajouter((int*)0);
   tp.appliquer([](int *p) {
      cout << *p << endl;
   });
}

Cette classe se protège contre l'injection de pointeurs nuls dans un tas_pointeurs<T> par un test sur chaque pointeur lors de l'ajout. Ceci permet à  appliquer(F) d'appliquer une fonction f de type F sans tester les pointeurs au préalable pour s'assurer qu'ils ne soient pas nuls.

Notez au passage que forcer un (int*)0 dans ajouter() est pervers, car nous créons ainsi un pointeur nul dont le type est volontairement distinct de std::nullptr_t. Un tel cas serait sans doute considéré dégénéré en pratique.

Il est possible d'améliorer ce code en éliminant le test sur un pointeur nul dans ajouter(), laissant le compilateur gérer le cas du pointeur nul sur la base de son type. Ceci ne couvre toutefois pas des cas dégénérés comme celui susmentionné – si votre code contient de tels cas, le test explicite dans ajouter() demeurera nécessaire.

#include "Incopiable.h"
#include <vector>
#include <algorithm>
#include <ostream>
template <class T>
   class tas_pointeurs : Incopiable {
      std::vector<T*> v;
   public:
      void ajouter(T *p) {
         v.push_back(p);
      }
      void ajouter(std::nullptr_t) { // no-op
      }
      //
      // ici, grà¢ce à  ajouter(), on n'a pas à  valider
      // chaque pointeur p car ils sont tous non-nuls
      //
      template <class F>
         void appliquer(F f) {
            for(auto p : v)
               f(p);
         }
      ~tas_pointeurs()  {
         for(auto p : v)
            delete p;
      }
   };
#include <iostream>
int main() {
   using namespace std;
   tas_pointeurs<int> tp;
   tp.ajouter(nullptr);
   for(int i = 0; i < 10; ++i)
      tp.ajouter(new int{i});
   // tp.ajouter((int*)0); // ceci poserait encore problème
   tp.appliquer([&](int *p) {
      cout << *p << endl;
   });
}

Voilà .

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !