Le langage de programmation parfait n'existe pas (encore)

L'évolution des idiomes dans les langages de programmation.

Chaque mois un post sur r/programming nous présente un nouveau langage de programmation prêt à détrôner Java et C++. Cette recrudescence de langages est dû à l’avènement de LLVM: maintenant n'importe qui peut créer son propre langage compilé en quelques (centaines d') heures. Certains arguent que la multiplication des langages entraîne une division des écosystèmes, mais je ne suis pas de cet avis. Je pense que de cette façon, chaque langage peut copier sur son voisin ses fonctionnalités les plus excitantes, et ainsi s'améliorer encore plus rapidement.

Dans cet article, je vais faire un tour d'horizon des paradigmes de la programmation moderne, et des "nouvelles" fonctionnalités les plus excitantes.

Mort à la POOOOOOOOO

Tous nos nouveaux langages semblent s'accorder sur un point : La Programmation Orienté Objet hardcore à la Java, c'est fini. Pour ceux qui ne sont pas trop familiers avec Java, il faut savoir que tout est un objet, ce qui donne des choses ... questionnables. Voici par exemple comment exécuter un Hello World en java:

public class HelloWorld {
	
    // la fonction statique main de la classe est automatiquement exécutée
	public static void main(String[] arg){
		System.out.println("Hello world");
	}
}
Exotique non ?

On doit donc créer une classe (même si on ne crée pas d'objet par la suite), puis créer une méthode statique main sur la classe qui va être exécutée automatiquement au lancement du fichier HelloWorld.java. C'est ... bancal.

Les langages plus récents comme Rust préferent retourner à une programmation plus impérative. Voici comment on pourrait faire un Hello World:

fn main() {
	println!("Hello World");
}
Un Hello World en Rust

Ici on réduit au maximum le boilerplate, non ? Mais la qualité d'un langage ne se fait pas sur la quantité de lignes tapées pour résoudre un programme, car sinon python serait en haut du podium. Il faut aussi considérer les garanties du langages: rapidité d'exécution, sécurité etc.

Mort au typage dynamique

De cette façon, on aperçoit la mort lente et douloureuse des langages qui ne présentent pas de typage fort. Contraindre une variable à avoir le même type permet ainsi d'éviter un tas d'erreurs à la con, dont le JavaScript est roi:

var jeanmich = "Jean Mich du 42";

// le type de jeanmich peut être changé sans aucun avertissement
jeanmich = false;
Le typage faible en JavaScript

Cette flexibilité pouvait être utile dans les petites pages web des années 2000 mais de nos jours le JavaScript fait tourner des serveurs web avec plusieurs dizaines de milliers de lignes de code. Un exemple comme au dessus n'est tout de suite plus trivial à débugger quand tu convertis mal du texte.

Ainsi est né le TypeScript, qui transpile un langage typé fort en JavaScript et qui permet de s'assurer de ne pas faire de conneries:

// l'annotation string peut être omise car elle est déduite de l'expression de droite
let jeanmich: string = "Jean Mich du 42";

// Error: Type 'false' is not assignable to type 'string'
jeanmich = false;
Garder sa santé mentale avec TypeScript

Comme on le voit avec le morceau de code du dessus, on peut omettre l'annotation du type la plupart du temps et retrouver la facilité d'écriture d'un langage typé faible.

C'est aussi devenu possible en C++ depuis le C++11 avec le mot clé auto, et ça permet notamment d'omettre d'écrire les types à rallonge:

using namespace std;

auto ptr = make_unique<string>("Bonjour :p");
// revient à
unique_ptr<string> ptr = make_unique<string>("Bonjour :p");
Le mot clé auto en C++

Gloire aux interfaces

Pour remplacer la POO et pour pouvoir toujours faire du bon petit polymorphisme des familles, nos nouveaux langages se basent surtout sur les interfaces (comme ce qu'on peut trouver en Java). Celles-ci prennent aussi le nom de trait en Rust. C'est notamment utile car ça résout le problème de l'héritage multiple:

#include <iostream>

class Guerrier {
	
    Guerrier() {
    	std::cout << "Constructeur de guerrier appelé" << std::endl;
    }
    
    void attaquer() {
    	std::cout << "SBLAM !" << std::endl;
    }
};

class Mage {
	
    Mage() {
    	std::cout << "Constructeur de mage appelé" << std::endl;
    }
    
    void lancerSort() {
    	std::cout << "ZIOUM !" << std::endl;
    }
    
};

class Personnage : Guerrier, Mage {
	
    Personnage() {
    	std::cout << "Constructeur de perso appelé" << std::endl;
    }
} 

int main() { 
    Personnage jeanMichelJarre; 
    
    /* affiche
    * Constructeur de perso appelé
    * Constructeur de guerrier appelé
    * Constructeur de mage appelé
    */
    
    jeanMichelJarre.lancerSort();
    // ZIOUM !
    jeanMichelJarre.attaqeur();
    // SBLAM !
    
    return 0; 
} 
Héritage multiple en C++

On ne veut pas forcément appeler les trois constructeurs dans notre cas. D'une façon générale, il faut avoir une idée très précise de notre "schéma d'héritage" dans la tête quand on fait ce genre de manip, ce qui peut vite devenir compliqué.

On pourrait ensuite en C++ récupérer jeanMichelJarre en tant que Mage ou Guerrier comme ceci:

Mage* mage =  &jeanMichelJarre;
mage.lancerSort();
// ZIOUM !

Guerrier* guerrier = &jeanMicheljarre;
guerrier.attaquer();
// SBLAM !

// ne marche pas par contre:
guerrier.lancerSort();
Polymorphisme grâce à l'héritage en C++

En Go, avec des interfaces, on ferait plutôt comme ça:

package main

import "fmt"

type Guerrier interface {
	attaquer()
}

type Mage interface {
	lancerSort()
}

type Personnage struct {
}

// cette syntaxe permet de déclarer une méthode pour Personnage
func (Personnage) attaquer() {
	fmt.Println("SBLAM !")
}

func (Personnage) lancerSort() {
	fmt.Println("ZIOUM !")
}

func main() {
	var jeanMichelJarre Personnage
	jeanMichelJarre.lancerSort()
	// ZIOUM !
	jeanMichelJarre.attaquer()
	// SBLAM !
}
Les interfaces avec Go

Avec Go, Personnage implémente automatiquement les interfaces Guerrier et Mage car il implémente lancerSort et attaquer.

Et pour récupérer notre Mage ou notre Guerrier:

var mage Mage = jeanMichelJarre
mage.lancerSort()
// ZIOUM !

var guerrier Guerrier = jeanMichelJarre
guerrier.attaquer()
// SBLAM !
Le polymorphisme en Go

Une approche différente de la gestion des erreurs

Là où cela devient un peu plus velu, c'est sur la gestion des erreurs. Tout le monde s'accorde à dire que les bon vieux try / catch de Java ou C++ ne sont pas optimaux: Ils forcent à imbriquer le code et gênent la compréhension. Voilà par exemple comment ouvrir un fichier en Java, d'après la documentation d'Oracle:

Charset charset = Charset.forName("US-ASCII");
try (BufferedReader reader = Files.newBufferedReader(file, charset)) {
	String line = null;
	while ((line = reader.readLine()) != null) {
		System.out.println(line);
	}
} catch (IOException x) {
	System.err.format("IOException: %s%n", x);
}
La gestion des erreurs avec les exceptions en Java

Non seulement c'est très verbeux (c'est du Java aussi !) mais si on veut faire des opérations  plus  complexes après l'ouverture du fichier on se retrouve à recouvrir son code de blocs try:

try {
	InputStreamReader isr = new InputStreamReader(System.in);
	BufferedReader br = new BufferedReader(isr);
	String str = br.readLine();
	int value = Integer.parseInt(str);
} catch(IOException | NumberFormatException e) {
	System.out.println(e.getMessage());
}
Un exemple de try / catch d'un de mes anciens cours de Java

Si bien que finalement on ne sait même plus quelle ligne peut lever une exception, ni de quel type celle-ci sera ! Encore pire, on peut simplement ignorer la partie gestion de l'erreur et le compilateur ne se plaindra pas.

De plus, les exceptions en Java et en C++ peuvent beaucoup ralentir le code, du moins historiquement parlant !

On peut alors aimer la gestion simpliste des choses du C:

char *str;
str = (char *) malloc(15 * sizeof(char));
if (ptr == NULL) {
	// allocation error !
    exit(1);
}
Un appel à malloc() susceptible d'échouer

Ici, la valeur de retour nous permet de vérifier si l'opération c'est bien faite, et on à déjà moins mal aux yeux qu'en Java. Mais là encore, on peut utiliser le pointeur str sans vérifier s'il est nul avant, et faire péter la baraque. De plus, cette méthode à un autre inconvénient, que l'on peut voir avec Go qui a aussi choisi cette voie pour la gestion des erreurs:

func (c *Command) Create() error {
	cmdFile, err := os.Create(fmt.Sprintf("%s/cmd/%s.go", c.AbsolutePath, c.CmdName))
	if err != nil {
		return err
	}
	defer cmdFile.Close()

	commandTemplate := template.Must(template.New("sub").Parse(string(tpl.AddCommandTemplate())))
	err = commandTemplate.Execute(cmdFile, c)
	if err != nil {
		return err
	}
	return nil
}
Une fonction prise dans Cobra, une bibliothèque populaire en Go

Les fonctions en Go renvoient typiquement des n-uplets, le dernier élément étant l'erreur. On voit qu'il y a encore beaucoup de boilerplate !

D'autres languages comme Kotlin ou Rust reprennent l'idée des monades d'Haskell: on enveloppe le résultat dans un type générique. Voilà à quoi cela peut ressembler ce type avec Rust:

enum Result<T, E> {
    Ok(T),
    Err(E),
}
Une définition simpliste du type Result d'après The Rust Book

L'idée est que le développeur qui reçoit ce type en retour d'une fonction est obligé de gérer l'erreur explicitement:  il n'y a donc plus de comportement indéfini à cause d'une fonction qui une fois sur deux peut renvoyer des erreurs qui ne seront pas gérées. Avec une petite dose de sucre syntaxique, on peut arriver à des résultats très jolis:

// fonction qui retourne un nombre si tout va bien
// sinon elle retourne un String qui décrit l'erreur
fn maybe_error(should_error_out: bool) -> Result<i32, String> {
	if should_error_out {
    	Err("J'ai paniqué !".to_string())
    } else {
    	Ok(42)
    }
}

// La fonction main ne retourne rien si tout va bien
// sinon elle retourne un String qui décrit l'erreur
fn main() -> Result<(), String> {
    let number = maybe_error(false)
    	// le point d'interrogation propage l'erreur, si la fonction à aussi un type de retour Result
    	.map_err(|e| format!("la fonction maybe_error a échoué: {}", e))?;
        
    println!("le nombre est {}", number);
    Ok(())
}

// selon si on appelle maybe_error(false) ou maybe_error(true) on aura
// "le nombre est 42"
// ou le programme quittera avec:
// "Error: "function maybe_error failed: J'ai paniqué !"
Utilisation de Result en Rust

Avec cette solution nous n'avons plus d'imbrication non nécessaire de code !

La fin du type null

Dans la même veine que le type  Result, il existe en Rust un type Option, qui correspond au type Haskell Either.


enum Option<T> {
    Some(T),
    None,
}
Une définition simpliste du type Option d'après The Rust Book

Cela signifie: soit j'ai une variable du type T, soit je n'ai rien du tout. Et je dois m'assurer qu'il y a une variable du type T avant d'appeler la moindre manipulation sur le type T, évitant ainsi l'erreur à un milliard de dollars:

Lors d'une conférence en 2009, Hoare « [s]'excuse d'avoir inventé le pointeur NULL » en ces termes : « Je  l’appelle mon erreur à un milliard de dollars. En 1965, je concevais le  premier système de typage complet pour un langage orienté objet et je  n'ai pas pu résister à ajouter la référence nulle, simplement parce que  c'était si facile à implémenter. Ceci a conduit à un nombre incalculable  d'erreurs, … qui ont probablement causé des dommages d'un milliard de  dollars dans les quarante dernières années. »

Extrait de l'article Wikipédia sur Hoare

Des fonctions asynchrones toujours à désirer

Trois exemples valent mille mots: prenons l'évolution du code asynchrone en NodeJS.

fs.readFile('hello.txt', (err, data) => {
	// callback appelé une fois que hello.txt a été lu
    if (err) {
        throw 'oups';
    }
    
    fs.readFile(data, (err2, data2) => {
     	// callback appelé une fois que le le deuxième fichier à été lu
        if (err2) {
            throw 'oups';
        }
        
        console.log(data2);
    }
    
}
    
Première version (< 2015)
fs.promises.readFile('hello.txt')
	.then(data => fs.promises.readFile(data))
	.then(data2 => console.log(data2))
    .catch(err => throw 'oups');
Deuxième version, déjà plus concis ! (2015)
try {
    let data = await fs.promises.readFile('hello.txt');
    let data2 = await fs.promises.readFile(data);
    console.log(data2);
} catch(e) {
	throw 'oups';
}
Troisième version (2017)

Malgré cette amélioration de la syntaxe, il reste encore beaucoup de problèmes à résoudre avec les fonctions asynchrones, et ce pour la plupart des langages. Ici on a le fameux try catch de ses morts qui réapparaît.

Mot de fin

J'ai appelé cet article "Le langage de programmation parfait n'existe pas (encore)". Le "encore" signifie que le langage de vos rêves est en pleine création 😎. Il s'appelle crocolang et c'est un petit truc que je mets en place depuis un peu moins d'un an. Le langage n'est absolument pas utilisable sérieusement pour le moment, le code n'est pas incroyable mais il a le mérite d'exister ! Je tiens à l'améliorer pour le rendre agréable à utiliser au quotidien !

Vous pouvez aller faire un tour ici pour en savoir plus !

truelossless/crocolang
Small and fun-to-use interpreted language written in Rust - truelossless/crocolang