Dans le cadre d'un cours sur les architectures logicielles à l'IMT, il a été décrit la notion de typage (Curry-Howard correspondence) permettant de développer des applications aux composants modulaires, sur la base de patrons de conception.
La correspondance dite de Curry-Howard s'appuie sur la théorie de la démonstration en logique. Elle affirme une identité de forme :
- entre les programmes et les preuves,
- entre les types et les propositions.
Logique | Langage | |
---|---|---|
Preuve | Programme | |
Proposition | Type | |
Relation | Démonstration | Habitation |
La preuve démontre la proposition si et seulement si le programme habite (a pour type) le type.
Une autre correspondance (moins structurante, d'où le fait qu'elle ne soit pas utilisée par les constructeurs de classe) pose une identité de forme :
- entre les programmes et les éléments,
- entre les types et les ensembles.
Théorie des ensembles | Langage | |
---|---|---|
Elément | Programme | |
Ensemble | Type | |
Relation | Appartenance | Habitation |
L'élément appartient à l'ensemble si et seulement si le programme habite (a pour type) le type.
-
Par agrégation : La relation "a un" ("possède un")
-
Par héritage : La relation "est un"
-
Avec l'agrégation avec délégation, l'injection est réalisée dynamiquement,
-
Avec l'héritage multiple, l'injection est réalisée statiquement, lors de la compilation
Ainsi, en Java, une méthode est identifiée non seulement par son nom mais aussi par le type de ses paramètres. En Typescript, seul le nom importe, puisqu'il en est ainsi en Javascript, langage dans lequel Typescript est compilé. Il est cependant possible de déclarer plusieurs méthodes de même nom, sans les définir.
interface X {
f(x: A): B;
f(x: C): D;
}
Cette déclaration est équivalente à une seule.
interface X {
readonly f: ((x: A) => B) & ((x: C) => D);
// type intersection noté aussi : { (x: A): B; (x: C): D; }.
}
Pourquoi peut-on utiliser heritageMulti
avec heritageType
alors qu'il n'hérite pas de A_default
?
interface I_Abstrait {
f(): void;
}
abstract class A_default implements I_Abstrait {
f(): void {
console.log("Abstrait.f");
}
}
interface I_Concret {
g(): void;
}
class Concret implements I_Concret {
g() {
console.log("Concret.g");
}
}
export function heritageType<C extends P, P>(
nomChamp: keyof P,
classeParente: any
): any {
return classeParente.prototype[nomChamp];
}
class heritageMulti extends Concret implements I_Concret, I_Abstrait {
public readonly f: () => void;
constructor() {
this.f = heritageMulti<heritageMulti, A_default>("f", A_default);
}
}
- TS :
- Sous typage structurel
- structure : ensemble des propriétés (méthodes et attributs)
- comparaison d'objet : fonction de la structure
- on n'a pas de classname en TS
- Java :
- Sous typage nominal
- nom : ce sont les noms des classes qui importent
- comparaison d'objet : fonction des attributs
- explicite: Il faut indiquer le nom du type avant le nom de la variable. (C, C++, java 9 et plus ancien...)
- implicite: Il n’est pas nécessaire d’indiquer le nom du type avant le nom de la variable.
- Statique: Les types sont chargés à la compilation (une variable ne peut donc pas changer de type à l’exécution)
- Dynamique: Les variables sont typées à l’exécution, le transtypage est donc possible (en Python par exemple). Le contrôle se fait à l’exécution.
- Loi externe de composition (loi d’action) : une loi qui s’applique sur des objets de types différents par le polymorphisme.
- Ex : 5 + 2,0 = 7,0
- Approche objet: les classes d’implémentation se basent sur la même interface pour interagir en protégeant l’intégrité de leurs données
- Loi interne de composition : loi s’appliquant sur deux objets de même type
- Ex : 5 + 2 = 7
- Approche fonctionnel: Si on veut changer la signature de la méthode, on doit étendre tout les types.
- approche fonctionnelle
- l’intra-opérabilité permet de spécialiser les calculs pour une implémentation donnée de l’interface.
- approche objet
- l’inter-opérabilité permet de rendre interopérable les différentes implémentations d’une interface
Le fichier heritageType.ts montre un exemple concret d'héritage de type.
You can not in TS.
function Test(): never => {
throw new Error();
}
Test(); // won't cause type error
let test: boolean = Test(); // will cause type error
When there is a possibility for a function to return a value, never
is absorbed by return type.
It's possible to specify it in function signature, but for reference only:
function Test(test: boolean): boolean | never {
if (test === true) return false;
throw new Error();
}
never
= type / ensemble vide
- throws une error
- switch -> type never pour signaler que l'on ne devrait pas arriver là
type Shape = Circle | Square;
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: never = shape; // => pas d'erreur
return _exhaustiveCheck;
}
}
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: never = shape; // Erreur, car on peut arriver dans ce cas là
return _exhaustiveCheck;
}
}
Vous permet d'utiliser un type plus dérivé que celui spécifié à l'origine.
Vous pouvez assigner une instance de IEnumerable<Derived>
à une variable de type IEnumerable<Base>
.
Vous permet d'utiliser un type plus générique (moins dérivé) que celui spécifié à l'origine.
Vous pouvez assigner une instance de Action<Base>
à une variable de type Action<Derived>
.
Signifie que vous pouvez utiliser uniquement le type spécifié à l’origine. Un paramètre de type générique indifférent n’est ni covariant ni contravariant.
Vous ne pouvez pas assigner une instance de List<Base>
à une variable de type List<Derived>
, ou vice versa.
- exemple d'ensemble inductif :
- arbre avec feuille
- liste : Une interface liste, et deux classes représentant “Vide” et un élément.
Le patron Composite, il représente le noyau de la définition inductive (à ne pas confondre avec Interprétation qui présente des fonctions supplémentaires)
Les méthodes nécessaires sont :
- les services offerts par le type abstrait de données,
- les sélecteurs, permettant de déterminer le cas de définition auquel correspond l'objet cible,
- les projecteurs, permettant de décomposer l'objet cible, lorsqu'il s'agit d'un cas construit.
- interprétation sert pour :
- il va servir à décrire les opération que le logiciel va pouvoir utiliser
Le patron Visiteur,Il permet d’éviter certains problèmes du patron Interprétation (qui n’est utilisable que pour l'implémentation de quelques fonctions)
Déplacement de responsabilité.
- Filtrage : le parcours récursif est à la charge du visiteur. On l’appelle “filtrage”, car le visiteur va devoir filtrer sur les sélecteurs (on parle de filtrage par cas). Il a l’avantage de permettre l’accès aux propriétés privées.
- Visite : le parcours récursif est à la charge du Composite. Il est fait à l’intérieur de l’accueil() dans la liste.
Il existe deux concepts fondamentaux pour le modèle d'itérateur :
- Iterable est une structure de données qui fournit un moyen d'exposer ses données au public. En JavaScript, l'implémentation est basée sur une méthode dont la clé est Symbol.iterator. En réalité, Symbol.iterator est une usine d'itérateurs.
- Iterator est une structure qui contient un pointeur vers l'élément suivant dans l'itération.
const iterable {
[Symbol.iterator](){
// Any code
}
}
/*********/
class iterable {
[Symbol.iterator](){
// Any code
}
}
Les modèles d'adaptateurs permettent essentiellement aux classes de travailler ensemble ce qu'elles ne pouvaient pas faire en raison d'interfaces incompatibles. L'adaptateur convertit l'interface d'une classe en quelque chose qui peut être utilisé par une autre classe.
De la même manière que si vous voyagez à l'étranger, vous devez emporter un adaptateur électrique pour pouvoir utiliser les prises murales.
Le modèle de stratégie, d'autre part, prend un groupe d'algorithmes et les rend interchangeables (en s'étendant à partir d'une interface commune). Ainsi, quelle que soit la classe qui va utiliser la stratégie, elle peut facilement l'échanger avec une autre stratégie du groupe.
En d'autres termes, l'adaptateur n'ajoute aucun comportement, il modifie simplement l'interface existante pour permettre à une autre classe d'accéder à la fonctionnalité existante.
Le modèle de stratégie, quant à lui, encapsule les différents comportements et permet de les changer au moment de l'exécution.
- Les États
- stockent une référence à l'objet de contexte qui les contient. Ce n'est pas le cas des stratégies.
- sont autorisés à se remplacer (IE : pour changer l'état de l'objet de contexte en quelque chose d'autre), tandis que les stratégies ne le sont pas.
- Les stratégies
- sont transmises à l'objet de contexte en tant que paramètres, tandis que les états sont créés par l'objet de contexte lui-même.
- ne gèrent qu'une seule tâche spécifique, tandis que les états fournissent l'implémentation sous-jacente pour tout (ou presque tout) ce que fait l'objet de contexte.
Certes les deux modèles sont des exemples de composition avec délégation, mais ils diffèrent :
- Le modèle d'état traite de ce qu'est (l'état ou le type) un objet (in) - il encapsule le comportement dépendant de l'état,
- le modèle de stratégie traite de la façon dont un objet effectue une certaine tâche - il encapsule un algorithme.
- Structuration
- Composite : Permet d'agencer les objets dans des arborescences afin de pouvoir traiter celles-ci comme des objets individuels.
- Comportement
- Interpréteur : définit la grammaire d'un langage utilisé pour décrire les opérations qu'ils peuvent réaliser et utilise celle-ci pour interpréter des états dans ce langage.
- Iterator : Permet de parcourir les éléments d’une collection sans révéler sa représentation interne (liste, pile, arbre, etc.).
- Strategy : Permet de définir une famille d’algorithmes, de les mettre dans des classes séparées et de rendre leurs objets interchangeables.
- Visitor : Permet de séparer les algorithmes et les objets sur lesquels ils opèrent.
Classe K correspondant au constructeur de termes k, avec deux attributs c : C et x : I
filtrage<R>(..., k : (c : C, x : I) => R, ...): R {
return v.k(this.c, this.x); // Pas de parcours récursif
}
Usage utilisant des λ-expressions (des fermetures)
function f(i : I) : R {
return i.filtrage( // filtrage ou pattern matching
...,
(c, x) => ..., // lambda-expression
...
);
}
L'usage des fermetures ("closures") ou des λ-expressions simplifie l'écriture du filtrage, qui se rapproche de la pratique dans les langages fonctionnels.
- λ abstractions :
x -> x
(x, y) -> x + y
x -> {
System.out.println("int : " + x);
return x;
}
- abstractions constantes :
- à gauche une classe et à droite une fonction, ou
- à gauche une référence d'objet et à droite une méthode de la classe à laquelle appartient l'objet.
I c = x -> Integer.toString(x);
c = Integer::toString; // Formulation équivalente
- Le corps d'une λ-abstraction peut contenir des variables libres :
- soit des variables locales,
- soit des paramètres.
- Restrictions : Variables doivent être constantes (final) ou considéré comme variable de classe interne (fermeture)
- Cette fermeture ne s'applique pas aux attributs d'une classe englobante qui seraient utilisés dans le corps d'une λ-abstraction : les attributs ne sont pas des variables, mais plutôt des opérateurs de projection ou de sélection pour un objet.
let somme: (x: number, y: number) => number = (x: number, y: number) => x + y;
let sommeALancienne: (x: number, y: number) => number = function (
x: number,
y: number
) {
return x + y;
};
Le corps des λ-abstractions peut contenir des variables libres, sans restriction, contrairement à Java. Une variable libre devient liée par fermeture lorsqu'elle quitte l'environnement.
Remarque : une λ-abstraction, contrairement à une fonction anonyme déclarée par function ne possède pas de paramètre implicite this.