La programmation orientée objet
Introduction
La programmation orientée objet (aussi connu sous l’acronyme : POO) est un des paradigmes de développement les plus utilisés pour réaliser des applications. Dans les débuts de la programmation, les logiciels étaient réalisés à partir de séquences d’instructions s’exécutant les unes après les autres, c’est ce que l’on appelle la programmation impérative. Au fil du temps, les programmes informatiques sont devenus plus complexes, ce qui augmenta considérablement la difficulté à les maintenir et les faire évoluer. Dans les années 70, Alan Kay posa les bases de la programmation orientée objet. Ce paradigme est devenu rapidement populaire et est encore utilisé aujourd’hui comme fondation dans bon nombre de logiciels que nous utilisons au quotidien. Ce style de programmation permet d’organiser un logiciel en utilisant des sous-ensembles plus petits, communément appelés objets. Chacun de ces objets contient des données et de la logique qui lui sont propres. Les objets sont donc des parties autonomes d’un programme et ont des interactions les uns avec les autres pour le faire fonctionner.
TypeScript est un langage dit multiparadigme. Il permet donc de développer des applications en utilisant la programmation impérative, orientée objet...
Les classes
La première notion importante à apprendre lorsque l’on commence l’apprentissage de la programmation orientée objet est le concept de classe. Chaque objet doit être créé par une classe. Celle-ci peut être comparée à une notice de fabrication qui contient l’ensemble des informations nécessaires à la création d’un objet. Une fois définie, la classe est utilisée dans le programme pour créer un objet, on parle alors d’instance de classe. Il est possible de créer autant d’objets que l’on souhaite pour faire fonctionner un programme.
Les objets vont ensuite interagir les uns avec les autres pour faire fonctionner le programme.
Pour déclarer une classe en TypeScript, il faut utiliser le mot-clé class, lui donner un nom et ouvrir les accolades pour définir ses caractéristiques.
Le concept de classe n’est pas nouveau en JavaScript. Le mot-clé class est un sucre syntaxique basé sur la notion de prototype dans le langage (cf. chapitre Types et instructions basiques). En ECMAScript 5, il est possible de définir une classe via une fonction constructrice. Celle-ci a la particularité de définir la structure des objets et de les créer. Depuis ECMAScript 2015, les classes sont privilégiées par rapport aux fonctions constructrices...
Les propriétés
Étant donné qu’un objet doit fonctionner de manière autonome, il est nécessaire qu’il conserve un état au cours de son utilisation. Cet état est contenu dans l’objet sous la forme de donnée. L’état d’un programme en programmation orientée objet correspondra donc à l’ensemble des états de chaque instance utilisée pour le faire fonctionner.
Pour assigner une donnée dans un objet en TypeScript, il faut utiliser une propriété. Celles-ci sont définies au niveau de la classe et doivent être typées. Pour déclarer une propriété, il suffit de l’ajouter entre les accolades de la classe en lui donnant un nom et en précisant ensuite son type.
Syntaxe :
class ClassName {
propertyName: type;
}
Exemple :
class Employee {
firstName: string;
lastName: string;
}
Une fois l’instance d’une classe créée, il est alors possible d’assigner des valeurs aux différentes propriétés, mais aussi de les récupérer. Les propriétés sont disponibles en utilisant un "." après le nom de la variable contenant l’objet.
Exemple :
let employee = new Employee();
employee.firstName = "Evelyn";
employee.lastName...
Les méthodes
Les données contenues dans un objet peuvent être par la suite utilisées lors de l’exécution de règle logique au sein du programme. Il est possible d’écrire ces règles de manière impérative ou en utilisant une fonction.
De son côté, la programmation orientée objet propose de définir cette logique directement dans les classes via des méthodes. Pour définir une méthode dans une classe, il suffit d’ajouter une fonction à l’intérieur de celle-ci.
Syntaxe :
class ClassName {
methodName(param1: type, param2: type, ...): type {
// ...
}
}
Au sein de la méthode, il sera ensuite possible de faire référence à l’instance courante de la classe en utilisant le mot-clé this. Cela permet de manipuler les propriétés d’un objet ou bien d’utiliser une autre méthode.
Exemple :
class Employee {
firstName!: string;
lastName!: string;
getFullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
const employee = new Employee();
employee.firstName = "Evelyn";
employee.lastName = "Miller";
const fullName = employee.getFullName();
// Log: Evelyn Miller
console.log(fullName);
Le mot-clé this a déjà été abordé précédemment (cf. chapitre Types et instructions basiques). Bien que son utilisation soit différente en programmation orientée objet, les règles de base liées au scope de this s’appliquent toujours. Attention donc à son utilisation dans une fonction renvoyée par une méthode. Il est toujours nécessaire d’utiliser un bind ou une fonction fléchée pour appliquer...
Les constructeurs
Dans la section Les propriétés de ce chapitre, l’initialisation des propriétés a été faite avec des valeurs par défaut. Il existe une manière plus élégante de définir ces valeurs lors de la création d’une instance de classe : les constructeurs.
Un constructeur est similaire à une méthode et il sera appelé automatiquement lors de l’instanciation d’une classe avec le mot-clé new. Pour définir un constructeur, il faut l’ajouter dans la classe en utilisant le mot-clé constructor. L’implémentation du constructeur est similaire à celle d’une méthode, mais il n’est pas possible de lui définir un nom et un type de retour.
Syntaxe :
class ClassName {
constructor(param1: type, param2: type, ...) {
// ...
}
}
Exemple :
class Employee {
firstName: string;
lastName: string;
constructor() {
this.firstName = "Evelyn";
this.lastName = "Miller";
}
getFullName(): string {
return `${this.firstName} ${this.lastName}`;
}
} ...
Le statisme
Il n’est pas toujours nécessaire qu’une méthode manipule des données contenues dans un objet. Il paraît alors superflu d’instancier une classe pour exécuter une méthode qui finalement ne manipulera rien au niveau de l’objet. Une simple fonction peut suffire dans ce genre de cas, mais si l’on souhaite conserver l’organisation d’un programme développé avec la programmation orientée objet, une autre solution est envisageable : le statisme.
Un membre d’une classe peut être déclaré statique via le mot-clé static. Il devient alors accessible directement depuis la classe plutôt qu’une instance de la classe. Il est alors inutile de créer une instance pour utiliser la méthode.
Syntaxe :
class ClassName {
static propertyName: type;
static methodName(param1: type, param2: type, ...): type {
// ...
}
}
Exemple :
class Employee {
static getFullName(
firstName: string,
lastName: string
): string {
return `${firstName} ${lastName}`;
}
}
const fullName = Employee.getFullName("Evelyn", "Miller"); ...
L’accessibilité des membres
Dans l’ensemble des exemples vus précédemment, lorsqu’une classe est instanciée, tous les membres qui la composent sont ensuite disponibles depuis l’extérieur de l’objet. On dit alors que la portée des membres de l’objet est publique.
En TypeScript, le concept d’accessibilité des membres est plus large. Il est possible de définir des portées différentes aux membres qui composent un objet, afin de préciser la manière dont les autres objets vont interagir avec.
La première portée utilisable en TypeScript est : publique. Lorsqu’un membre d’une classe est marqué avec le mot-clé public, il devient par la suite accessible depuis l’extérieur de l’objet.
Syntaxe :
class ClassName {
public propertyName: type;
public methodName(param1: type, param2: type, ...): type {
// ...
}
}
Exemple :
class Employee {
public firstName: string;
public lastName: string;
public constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
public getFullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
const employee = new Employee("Evelyn", "Miller");
// Log: Evelyn Miller
console.log(employee.getFullName());
employee.firstName = "John";
employee.lastName = "Riley";
// Log: John Riley
console.log(employee.getFullName());
Les membres d’une classe en TypeScript sont publics par défaut, il n’est donc pas nécessaire d’utiliser le mot-clé public pour définir la portée d’un membre, car elle est implicite. Toutefois, certains développeurs préfèrent la préciser explicitement afin d’améliorer la lisibilité du code. Dans la suite de ce chapitre, la portée publique sera toujours utilisée de manière implicite.
En opposition...
L’encapsulation
L’encapsulation est le premier pilier de la programmation orientée objet. Ce principe stipule qu’il est préférable de contrôler la modification de l’état d’un objet, depuis l’intérieur plutôt que depuis l’extérieur. Sa mise en œuvre implique que les propriétés ne doivent pas être exposées publiquement, elles doivent être encapsulées. Chaque propriété sera alors cloisonnée et devra donc être déclarée avec une portée privée.
Pour être manipulées depuis l’extérieur, des méthodes peuvent être implémentées pour obtenir ou redéfinir la valeur de la propriété.
Exemple :
class Employee {
#salary: number = 0;
getSalary() {
return this.#salary;
}
setSalary(salary: number) {
const isNegative = salary < 0;
if (!isNegative) {
this.#salary = salary;
}
}
}
const employee = new Employee();
employee.setSalary(-2000);
// Log: 0
console.log(employee.getSalary()); ...
L’héritage
L’héritage est le second pilier de la programmation orientée objet. C’est un principe particulièrement utile qui permet à une classe de récupérer les caractéristiques d’une autre classe lorsqu’elle en hérite. En TypeScript, une classe peut donc hériter d’une autre via le mot-clé extends.
Syntaxe :
class Class1 extends Class2 {
//...
}
Lorsqu’une classe hérite d’une autre classe, elle devient alors compatible avec le type de la classe sur laquelle elle se base. Cela permet à une classe dérivée d’être ensuite manipulée comme si elle était du type de la classe de base dont elle hérite. Cette particularité est fondamentale pour mettre en place le concept du polymorphisme qui sera abordé dans la suite de ce chapitre (cf. section Le polymorphisme).
Exemple :
class Person {
constructor(
public firstName: string,
public lastName: string
) {}
getFullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
class Employee extends Person {
salary!: number;
}
const employee = new Employee("Evelyn", "Miller");
employee.salary = 2000;
// Log: Evelyn Miller
console.log(employee.getFullName());
Lorsque le type dérivé est manipulé en tant que type de base, il n’est plus possible d’accéder aux membres qui lui sont propres.
Exemple :
const person: Person = new Employee("John", "Riley");
// Compilation Error TS2339:
// Property 'salary' does not exist on type 'Person'.
person.salary = 2000;
Une fois qu’une classe hérite d’une autre, elle en récupère l’ensemble des caractéristiques. Il est donc possible, depuis la classe dérivée, de manipuler les membres définis dans la classe de base avec le mot-clé this.
Exemple :
class Person { ...
L’abstraction
En programmation orientée objet, une classe représente généralement un objet du monde réel (exemple : Employe, Chien, Avion, MicroControleur…). Cependant, certains objets peuvent être de nature immatérielle et n’ont donc pas de représentation dans le monde réel (exemple : Personne, Animal, Vehicule, Circuit…). Ces types d’objets sont donc considérés comme abstraits et ils n’ont de sens dans un programme que s’ils sont hérités et complétés par une autre classe.
En TypeScript, il est possible de créer une classe abstraite en utilisant le mot-clé abstract. Étant donné leur nature, ces classes ne peuvent pas être instanciées. Il est donc nécessaire d’utiliser l’héritage pour créer une classe concrète qui sera alors instanciable.
Syntaxe :
abstract class ClassName {
// ...
}
Exemple :
abstract class Person {
constructor(
public firstName: string,
public lastName: string
) {}
getFullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
// Compilation Error TS2511: ...
Les interfaces
En programmation orientée objet, le concept d’interface permet de définir des abstractions sans avoir besoin d’écrire de classes. Une interface contient une description de ce qui sera implémenté par une classe (on parle parfois de contrat). La nature des interfaces induit que les membres décrits à l’intérieur sont publics et peuvent donc être accessibles par n’importe quel autre objet, à partir du moment où celui-ci fait référence à l’interface.
Pour déclarer une interface en TypeScript, il faut utiliser le mot-clé interface, lui donner un nom et ouvrir les accolades pour définir ses caractéristiques.
Syntaxe :
interface InterfaceName {
// ...
}
Exemple :
interface Person {
// ...
}
Le concept d’interface n’existe pas dans ECMAScript, c’est pourquoi le compilateur TypeScript ne génère pas de code JavaScript lors de la transpilation. Il est donc important de garder en tête que ce concept est utile uniquement à la compilation. Il sert principalement au système de type du langage.
Syntaxiquement, une interface ressemble à une classe. Cependant, à la différence de ces dernières, les interfaces définissent des membres, mais pas leur implémentation. Les méthodes n’ont donc pas de corps et il n’est pas possible de définir des accesseurs ou un constructeur.
La signature d’un constructeur en TypeScript peut être définie dans une interface, mais l’implémentation de celle-ci dans une classe devient alors impossible. Cette particularité vient du fait que le mot-clé class est un sucre syntaxique et que le constructeur fait référence à la fonction constructrice.
Syntaxe :
interface InterfaceName {
propertyName: type;
methodName(param1: type, param2: type, ...): type;
}
Exemple :
interface Person {
firstName: string;
lastName: string;
getInformation(): string;
}
Les interfaces n’ont pas besoin de préciser la portée des membres, ils sont obligatoirement publics. De plus, les propriétés définies...
Le polymorphisme
Le polymorphisme est le troisième pilier de la programmation orientée objet. C’est un concept qui permet de traiter des objets de différents types d’une manière identique (on parle généralement d’interface ou de méthode unique pouvant traiter plusieurs types).
Il existe plusieurs mises en œuvre possibles du polymorphisme. La première consiste à définir plusieurs surcharges d’une méthode pour chaque type qu’elle doit traiter (cf. section Les méthodes).
La deuxième approche possible est de se baser sur les capacités d’abstraction du langage. C’est une solution extensible et facile à maintenir par la suite, c’est pourquoi elle est généralement préférée à la première solution. En TypeScript, les méthodes travailleront ou accepteront en paramètre des abstractions. Il faut donc utiliser des classes abstraites ou des interfaces pour mettre en œuvre le polymorphisme dans une classe.
Exemple :
interface Payable {
sendPayment(): void;
}
class Supplier implements Payable {
constructor(
public readonly name: string,
private readonly invoice: number
) {}
sendPayment():...
Les principes SOLID
1. Introduction aux principes SOLID
Les principes SOLID ont été introduits par Robert C. Martin. Ils correspondent à un ensemble de bonnes pratiques autour de la programmation orientée objet.
Sous l’acronyme SOLID se cachent les cinq principes suivants :
-
Single responsibility (Responsabilité unique)
-
Open/Closed (Ouvert à l’extension, fermé à la modification)
-
Liskov substitution (Substitution de Liskov)
-
Interface segregation (Ségrégation des interfaces)
-
Dependency inversion (Inversion des dépendances)
La mise en place de ces principes permet d’améliorer significativement la maintenabilité et l’évolutivité du code d’une application. Il est donc fortement conseillé de les mettre en œuvre.
2. Le principe de responsabilité unique
Le principe de responsabilité unique définit qu’une classe a une responsabilité unique dans un programme. La multiplication des responsabilités au sein d’une classe complexifie son code, sa lecture, sa maintenabilité, son évolutivité, ce qui augmente significativement les probabilités de créer un bug.
Ne pas appliquer ce principe conduit souvent à créer au sein du programme des "God Object". On dit qu’un objet devient un "God Object" quand il cumule trop de responsabilités. La présence de "God Object" dans un programme est considérée comme une mauvaise pratique.
Exemple (non-respect du principe) :
class Employee {
constructor(
readonly firstName: string,
readonly lastName: string,
readonly teams: [string, Employee[]][]
) {}
getTeams() {
return this.teams;
}
createTeam(teamName: string) {
if (!this.teams.some(t => t[0] === teamName)) {
this.teams.push([teamName, []]);
}
}
addMemberToTeam(teamName: string, employee: Employee) {
const filteredTeam = this.teams.filter(t...