Le compromis avec la compréhension acquise en utilisant un langage de bas niveau comme C est le manque de fonctionnalités. Lorsque vous écrivez du code à des fins de démonstration, ce n’est pas vraiment un problème. Mais lorsque vous devez écrire une application réelle, ça l’est.
L’une des fonctionnalités notables manquantes en C est la généricité à la compilation.
La généricité à la compilation est un type de duplication de code qui dépend des contraintes suivantes :
- Il existe plusieurs classes qui implémentent le même trait
- Il existe du code qui ne dépend de ces classes qu’à travers le trait implémenté
Dans cet article, je veux vous montrer une manière de reproduire la généricité en C, en particulier en ce qui concerne un trait d’opération dont dépendront toutes les opérations élémentaires sur les matrices. Nous allons le faire avec la seule méthode intégrée de duplication de code en C : les macros.
Que sont les macros en C ?
Les macros en C sont créées avec la directive #define. Elles sont utilisées le plus simplement comme ceci :
#define UN_NOMBRE_IMPORTANT 42
Ce qui remplace toutes les occurrences de UN_NOMBRE_IMPORTANT par le littéral 42. Vous pourriez vous demander : En quoi est-ce différent de cette définition globale ?
const int UN_NOMBRE_IMPORTANT = 42;
int main() { ... }
Pratiquement, pas grand-chose. Mais c’est parce que c’est si simple. Et si nous écrivions une macro fonction ?
#define CARRE_MACRO(x) x*x
int carre_fonction(int x) { return x*x; }
Quelle est la différence entre ces deux ? Cela devient clair lorsque vous pensez aux macros comme à un remplacement légèrement plus compliqué. Elles sont calculées par le préprocesseur C et remplacent un morceau de code par un autre au moment de la compilation. Par exemple :
int x = CARRE_MACRO(2);
se développe en :
int x = 2*2;
Alors que la fonction n’est pas développée en autre chose qu’une instruction d’appel. La macro a quelques avantages. Premièrement, elle n’a pas la surcharge d’un appel de fonction. Deuxièmement, elle n’est pas typée, donc CARRE_MACRO peut être utilisée avec n’importe quel type qui supporte la multiplication.
La nature des macros qui procure ces avantages crée également un certain nombre de pièges dangereux. Voyons ce que CARRE_MACRO développe dans quelques cas intéressants.
CARRE_MACRO(2 + 3)
2 + 3*2 + 3
Nous voulions 25, mais nous avons obtenu 11. Pas bon. Nous pouvons facilement résoudre ce problème.
#define CARRE_MACRO (x)*(x)
Un autre exemple :
int x = 1, sum = 0;
while (x < 10) { sum += CARRE_MACRO(x++); }
se développe en :
int x = 1, sum = 0;
while (x < 10) { sum += (x++)*(x++); }
Nous voyons que x est incrémenté deux fois au lieu d’une ! En plus de cela, il calcule x*(x+1), pas x*x.
Nous voyons donc que cette simple macro produit des résultats complètement absurdes pour certaines entrées, qui sont considérées comme valides par le compilateur. Bien que le fait que les macros dupliquent le code cause ces comportements dangereux, cela nous permet également d’être plus efficaces.
Objectifs pour la bibliothèque
Je veux que la bibliothèque soit minimaliste (uniquement un en-tête), mais complète. Nous allons écrire des fonctions pour une gamme d’opérations entre deux matrices et .
J’ai classé les fonctions nécessaires comme suit.
- Type d’allocation : Devons-nous mettre le résultat dans un tampon , ou l’écrire dans le premier argument ?
- Type de boucle : Faisons-nous une boucle de type produit scalaire, ou une opération élément par élément ? Devons-nous transposer le second argument ?
- Type d’opération élément par élément : Multiplions-nous ? Additionnons-nous ? Soustraitons-nous ?
Remarquez que toutes les fonctions (sauf le produit scalaire) sont génériques par rapport à l’opération .
Boilerplate
Avant de commencer, nous devons définir notre type de matrice. Je vais également définir un type appelé mfloat, que nous pourrons substituer plus tard par n’importe quel type de flottant.
#define DEBUG 1
typedef double mfloat;
typedef struct {
mfloat *buf;
int rows;
int cols;
} matrix;
Nous allons supposer que les éléments sont stockés dans buf en ordre row-major. Maintenant, nous avons besoin de quelques fonctions de base getter-setter avec une vérification optionnelle des limites que nous utiliserons dans le reste de la bibliothèque.
static inline mfloat matrix_get(matrix m, int row, int col) {
if (DEBUG)
if (!(row >= 0 && col >= 0 && row < m.rows && col < m.cols)) {
fprintf(
stderr,
"matrix_get: Index hors limites (%d, %d) pour la taille de la matrice "
"(%d, %d)\n",
row, col, m.rows, m.cols);
exit(1);
}
return m.buf[row * m.cols + col];
}
static inline void matrix_set(matrix m, int row, int col,
mfloat val) {
if (DEBUG)
if (!(row >= 0 && col >= 0 && row < m.rows && col < m.cols)) {
fprintf(
stderr,
"matrix_set: Index hors limites (%d, %d) pour la taille de la matrice "
"(%d, %d)\n",
row, col, m.rows, m.cols);
exit(1);
}
m.buf[row * m.cols + col] = val;
}
Et nous avons besoin d’un moyen d’afficher la matrice sur la console
static inline void matrix_print(matrix m) {
for (int i = 0; i < m.rows; i++) {
printf("[ ");
for (int j = 0; j < m.cols; j++) {
// Notation scientifique, arrondie à 4 décimales
printf("%.4e", matrix_get(m, i, j));
printf(" ");
}
printf("]\n");
}
printf("\n");
}
Et un moyen d’allouer et de libérer des matrices sur et depuis le tas
matrix matrix_new(int rows, int cols) {
double *buf = calloc(rows * cols, sizeof(double));
if (buf == NULL) {
printf("matrix_new: calloc a échoué.");
exit(1);
}
return (matrix){
.buf = buf,
.rows = rows,
.cols = cols,
};
}
Produit scalaire
Commençons par le produit scalaire. Je vais seulement écrire une version avec allocation de tampon puisqu’il n’est possible d’écrire la sortie dans le premier argument que si et seulement si les deux matrices sont carrées, ce que nous ne pouvons pas supposer.
static inline void matrix_dot(matrix out, const matrix m1,
const matrix m2) {
if (DEBUG)
if (m1.cols != m2.rows) {
printf(
"matrix dot: erreur de dimension (%d, %d) non compatible avec "
"(%d, %d)\n",
m1.rows, m1.cols, m2.rows, m2.cols);
exit(1);
}
for (int row = 0; row < m1.rows; row++) {
for (int col = 0; col < m2.cols; col++) {
double sum = 0.0;
for (int k = 0; k < m1.cols; k++) {
double x1 = matrix_get(m1, row, k);
double x2 = matrix_get(m2, k, col);
sum += x1 * x2;
}
matrix_set(out, row, col, sum);
}
}
}
Opérations élément par élément
Chaque opération élément par élément fait ce qui suit :
- S’assurer que les deux matrices ont les mêmes dimensions
- Exécuter l’opération sur chaque élément correspondant
- Mettre le résultat dans la matrice de sortie
Nous voyons que la boucle sera identique pour chacune des fonctions, alors écrivons une macro pour cela
#define MAT_ELEMENTWISE_LOOP \
for (int i = 0; i < m1.rows; i++) \
for (int j = 0; j < m1.cols; j++)
Et une fonction pour vérifier les limites, qui panique simplement si les limites ne correspondent pas.
static inline void mat_bounds_check_elementwise(const matrix out,
const matrix m1,
const matrix m2) {
if (DEBUG)
if (m1.rows != m2.rows || m1.cols != m2.cols ||
out.rows != m1.rows || out.cols != m1.cols) {
fprintf(stderr,
"Dimensions incompatibles pour l'opération élément par élément "
"(%d, %d) & (%d, %d) => (%d, %d) \n",
m1.rows, m1.cols, m2.rows, m2.cols, out.rows,
out.cols);
exit(1);
}
}
Maintenant, nous voulons implémenter l’addition, la multiplication, la division et la soustraction. Comme tout le code sauf le calcul réel est identique, nous pouvons l’abstraire dans une macro qui définit la fonction.
#define DEF_MAT_ELEMENTWISE_BUF(opname, op) \
static inline void matrix_##opname( \
matrix out, const matrix m1, const matrix m2) { \
mat_bounds_check_elementwise(out, m1, m2); \
MAT_ELEMENTWISE_LOOP { \
mfloat x = matrix_get(m1, i, j); \
mfloat y = matrix_get(m2, i, j); \
matrix_set(out, i, j, op); \
} \
}
##opnameinsère la valeur deopnamedans le nom de la fonction.
En anticipant, nous savons que nous devrons définir les fonctions d’addition, de multiplication, de division et de soustraction pour toutes les variations, alors écrivons une macro qui fait cela pour une macro donnée définissant une fonction
#define DEF_ALL_OPS(OP_MACRO) \
OP_MACRO(sub, (x - y)); \
OP_MACRO(add, (x + y)); \
OP_MACRO(div, (x / y)); \
OP_MACRO(mul, (x * y));
Maintenant, nous pouvons réellement définir les 4 fonctions !
DEF_ALL_OPS(DEF_MAT_ELEMENTWISE_BUF)
Boom ! En une ligne, nous avons défini les fonctions matrix_add, matrix_sub, matrix_div, et matrix_mul.
Maintenant, essayons d’implémenter les opérations en place.
static inline void mat_bounds_check_elementwise_ip(
matrix m1, const matrix m2) {
if (DEBUG)
if (m1.rows != m2.rows || m1.cols != m2.cols) {
fprintf(stderr,
"Dimensions incompatibles pour l'opération élément par élément en place "
"(%d, %d) & (%d, %d) \n",
m1.rows, m1.cols, m2.rows, m2.cols);
exit(1);
}
}
#define DEF_MAT_ELEMENTWISE_IP(opname, op) \
static inline void matrix_ip_##opname(matrix m1, \
const matrix m2) { \
mat_bounds_check_elementwise_ip(m1, m2); \
MAT_ELEMENTWISE_LOOP { \
mfloat x = matrix_get(m1, i, j); \
mfloat y = matrix_get(m2, i, j); \
matrix_set(m1, i, j, op); \
} \
}
Avec cette nouvelle macro, nous pouvons simplement faire
DEF_ALL_OPS(DEF_MAT_ELEMENTWISE_IP)
et matrix_ip_add, matrix_ip_mul, etc. sont définies. Nous pouvons également faire cela pour les opérations de transposition, mais je ne le montrerai pas ici.
Opérations unaires
Parfois, nous voulons faire une opération unaire sur une matrice , comme le scalaire ou . Faisons une fonction unaire générique par rapport à l’opération.
#define DEF_MAT_UNARY_IP(opname, op) \
static inline void matrix_ip_##opname(matrix m1) { \
MAT_ELEMENTWISE_LOOP { \
mfloat x = matrix_get(m1, i, j); \
matrix_set(m1, i, j, op); \
} \
}
DEF_MAT_UNARY_IP(square, (x * x))
DEF_MAT_UNARY_IP(negate, (-x))
DEF_MAT_UNARY_IP(sqrt, (sqrt(x)))
Et maintenant matrix_ip_square(A), matrix_ip_negate(A), etc. sont définies.
Conclusion
Juste comme ça, nous avons “écrit” une bibliothèque complète d’opérations sur les matrices avec un effort minimal. Mais cela soulève la question : Ce code est-il sûr ?
Tant que vous #undef toutes les macros que vous avez définies, oui, il est aussi sûr que d’écrire toutes les fonctions vous-même. D’un autre côté, si vous exposez des macros dans la fonctionnalité de votre bibliothèque, elle pourrait ne plus être sûre. Mais juste parce qu’un code est sûr ne signifie pas que c’est quelque chose que vous devriez faire. S’il y a un problème avec le code, il peut être difficile à déboguer puisque vous ne pouvez pas voir ce qu’il développe. Donc, dans un environnement de production, l’utilisation de macros est fortement déconseillée. Je l’ai simplement utilisée ici parce que c’était une solution facile pour une série éducative.
Si vous avez des questions ou des suggestions, n’hésitez pas à laisser un commentaire ou à m’envoyer un email. Merci d’avoir lu.