Le code et ses raisons
  1. Conventions : le retour
  2. Le code et ses raisons : goto en C
  3. Le code et ses raisons : typedef en C
  4. Pourquoi le C est moins puissant que votre langage favori
  5. Préprocesseur C : récursivité ou imbrication ?
  6. Le C et ses raisons : les pointeurs restreints
  7. Le C et ses raisons : assertions ou programmation défensive ?

Pourquoi le C est moins puissant que votre langage favori

(Nhat Minh Lê (rz0) @ 2010-03-27 02:38:35)

On entend souvent dire que l’on peut tout faire dans un langage comme dans un autre, Turing-complétude, tout ça. La variante de ce discours est que l’on peut tout faire en C parce que c’est un langage de bas niveau. Certes, on peut tout faire, mais on ne peut pas tout faire aussi bien, c’est-à-dire aussi efficacement.

Ainsi, avant de poursuivre avec mes articles sur les techniques de programmation en C, j’ai décidé de prendre le temps d’écrire un court billet sur ce que l’on ne peut pas faire en C. Ce petit texte n’a pas pour prétention d’être exhaustif, car la liste de ce que l’on ne peut pas faire en C est sûrement longue, très longue. Mais je souhaite donner ici quelques points de réflexion et principes de base, pour les plus débutants d’entre nous.

Le problème

Pourquoi donc ne peut-on pas faire aussi efficacement en C certaines choses que l’on peut bien faire dans un autre langage ? La réponse tient en cela : l’abstraction.

L’argument que l’on entend souvent est le suivant : le C étant plus bas niveau, il suffit de recoder les mécanismes internes abstraits par tel ou tel langage de plus haut niveau. Je suis d’accord avec cette méthode, je l’aime même beaucoup, étant moi-même assez spécialisé dans l’implémentation des langages. Mais il est important d’en connaître les limites.

Les limites, ce sont les limites de définition du langage. Si l’on veut utiliser le C comme un langage raisonnablement portable, et que l’on s’en tient à la norme, on hérite par la même occasion de contraintes normées, plus fortes que celles imposées par la plateforme pour laquelle on développe.

Concrètement, cela signifie que sur une machine donnée, votre beau C portable ne pourra pas recourir aux mêmes astuces que l’implémentation d’un langage de plus haut niveau (qui, elle, n’est pas portable). Prenez par exemple les variables de Scheme, ou tout autre langage dynamique. Dans ces langages, une variable peut pointer sur un objet, ou contenir une valeur numérique unboxed. En profitant de la représentation des pointeurs au sein du système hôte, et du fait que la mémoire n’y est jamais allouée que sur un alignement de 2, on peut utiliser un bit pour déterminer la nature de l’objet (boxed ou unboxed).α C’est malin comme tout, et vieux comme le monde. Mais inapplicable en C. Question de portabilité. En effet, on n’a même pas la garantie que les pointeurs soient convertibles en entiers !

Mais, mais, me direz-vous, en Scheme non plus, on n’a pas cette garantie ! Oui… mais en Scheme, il n’y a pas de pointeurs comme en C ! Autrement dit, le programmeur s’en contrefiche : il utilise simplement son langage, et c’est au compilateur de décider comment telle ou telle fonctionnalité est traduite au niveau de la machine ; du point de vue du langage, on a perdu…

α : Ce n’est pas un article sur l’implémentation des langages, voyez la page Wikipédia sur l'unboxing, si vous n’êtes pas à l’aise avec ces notions.

La solution ?

Mais on n’a qu’à implémenter des solutions spécifiques en plus de la version générale moins efficace ! C’est en effet une possibilité.

Par exemple, pour les objets dynamiquement polymorphes pointeurs / entiers, on pourrait se définir un petit jeu de macros dans ce genre-là :

#include <stdint.h>

#if UINTPTR_MAX && HAVE_2ALIGNPTRS

typedef uintptr_t VariantInt;
typedef uintptr_t Variant;

#define ISINT(x) ((x) & 0x1)
#define INTVAL(x) ((x) >> 1)
#define PTRVAL(x) ((void *)(x))
#define SETINT(x, y) ((x) = (y) << 1 | 0x1)
#define SETPTR(x, y) ((x) = (uintptr_t)(y))

#else

#if UINTPTR_MAX
typedef uintptr_t VariantInt;
#else
typedef long VariantInt;
#endif
typedef struct {
        union {
                VariantInt _int;
                void *_ptr;
        } _val;
        unsigned _isint: 1;
} Variant;

#define ISINT(x) ((x)._isint)
#define INTVAL(x) ((x)._val._int)
#define PTRVAL(x) ((x)._val._ptr)
#define SETINT(x, y) ((x)._val._int = (y), (x)._isint = 1)
#define SETPTR(x, y) ((x)._val._ptr = (y), (x)._isint = 0)

#endif

Bref, un truc du genre. Pas le plus beau jeu de macros du monde, mais vous comprenez le principe.

Et vous vous attendez sans doute maintenant à ce que je réfute cet argument… et bien non ! En réalité, c’est une manière parfaitement viable d’étendre un peu le langage. Cependant, elle requiert quelques précautions.

Que retenir de tout ça ?

Au final, je dirais, pas grand chose, si ce n’est que j’essaierai, pour ma part, d’être précis quant aux implications des méthodes que je décris. Ce n’est pas (seulement) pour être pédant ; je pense qu’il est réellement important de comprendre (pour mieux ignorer, dirons certains) les limites des définitions et des standards que l’on accepte, parfois sans le dire.

À bientôt donc, pour de nouvelles aventures ! :)