Tutoriel pour apprendre le langage Scala par l'exemple


précédentsommairesuivant

16. Inférence de type Hindley/Milner

Ce chapitre présente les types de données de Scala et la reconnaissance de motif en développant un système d'inférence de type à la Hindler/Milner (8). Le langage source pour l'inférenceur de type est Mini-ML, du lambda-calcul avec une instruction let. Les arbres syntaxiques abstraits pour Mini-ML sont représentés par les types de données suivants :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
abstract class Term {}
case class Var(x: String) extends Term {
    override def toString = x
}
case class Lam(x: String, e: Term) extends Term {
    override def toString = "(\\" + x + "." + e + ")"
}
case class App(f: Term, e: Term) extends Term {
    override def toString = "(" + f + " " + e + ")"
}
case class Let(x: String, e: Term, f: Term) extends Term {
    override def toString = "let " + x + " = " + e + " in " + f
}

Il y a donc quatre constructeurs d'arbres : Var pour les variables, Lam pour les abstractions de fonctions, App pour l'application des fonctions et Let pour les expressions let. Chaque case classe redéfinit la méthode toString de la classe Any de sorte que les termes s'affichent de façon lisible.

Nous définissons ensuite les types qui sont calculés par le système d'inférence :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
sealed abstract class Type {}
case class Tyvar(a: String) extends Type {
 override def toString = a
}
case class Arrow(t1: Type, t2: Type) extends Type {
 override def toString = "(" + t1 + "->" + t2 + ")"
}
case class Tycon(k: String, ts: List[Type]) extends Type {
 override def toString =
 k + (if (ts.isEmpty) "" else ts.mkString("[", ",", "]"))
}

Il y a trois constructeurs de types : Tyvar pour les variables de type, Arrow pour les types des fonctions et Tycon pour les constructeurs de types comme Boolean ou List. Ce dernier prend en paramètre la liste de leurs paramètres de type - elle est vide pour les constantes de type comme Boolean. Les constructeurs de types implémentent également la méthode toString pour que les types s'affichent correctement.

Notez que Type est une classe fermée (sealed), ce qui signifie qu'on ne peut pas l'étendre avec des sous-classes ou des constructeurs de types en dehors de la suite de définitions dans lequel il est défini. Type est donc un type algébrique fermé qui n'a que trois possibilités. Term, au contraire, est un type algébrique ouvert pour lequel on pourra définir d'autres possibilités.

Les parties principales de l'inférenceur de type sont contenues dans l'objet typeInfer. Nous définissons d'abord une fonction utilitaire qui crée de nouvelles variables de type :

 
Sélectionnez
1.
2.
3.
4.
object typeInfer {
    private var n: Int = 0
    def newTyvar(): Type = { n += 1; Tyvar("a" + n)
}

Nous définissons ensuite une classe pour les substitutions. Une substitution est une fonction idempotente des variables de types vers les types. Elle fait correspondre un nombre fini de variables de types à des types et ne modifie pas les autres variables de types. La signification d'une substitution est étendue à une correspondance point à point des types vers les types.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
abstract class Subst extends Function1[Type,Type] {

    def lookup(x: Tyvar): Type

    def apply(t: Type): Type = t match {
        case tv @ Tyvar(a) => val u = lookup(tv); if (t == u) t else apply(u)
        case Arrow(t1, t2) => Arrow(apply(t1), apply(t2))
        case Tycon(k, ts) => Tycon(k, ts map apply)
    }

    def extend(x: Tyvar, t: Type) = new Subst {
        def lookup(y: Tyvar): Type = if (x == y) t else Subst.this.lookup(y)
    }
    val emptySubst = new Subst { def lookup(t: Tyvar): Type = t }
}

Les substitutions sont représentées par des fonctions de type Type => Type. Pour ce faire, la classe Subst hérite du type de fonction unaire Function1[Type, Type] (9). Pour être une instance de ce type, une substitution s doit implémenter une méthode apply prenant un Type comme paramètre et renvoyant un autre Type comme résultat. Une application de fonction s(t) est alors interprétée comme s.apply(t).

Dans la classe Subst, la méthode lookup est abstraite. Il existe deux formes concrètes de substitutions qui diffèrent par leur implémentation de cette méthode. La première est définie par la valeur emptySubst, la seconde par la méthode extend de la classe Subst.

Le type suivant définit les schémas de types, qui sont formés d'un type et d'une liste de noms de variables de types qui apparaissent avec des quantificateurs universels dans le schéma. Le schéma kitxmlcodeinlinelatexdvp\forall a \forall b.a \rightarrow bfinkitxmlcodeinlinelatexdvp, par exemple, serait représenté sous la forme suivante dans le vérificateur de type :

 
Sélectionnez
TypeScheme(List(Tyvar("a"), Tyvar("b")), Arrow(Tyvar("a"), Tyvar("b")))

La définition de classe des schémas de types n'a pas de clause extends, ce qui signifie que ces schémas sont des classes qui héritent directement de AnyRef. Bien qu'il n'y ait qu'un moyen possible de construire un schéma de type, nous avons choisi d'utiliser une case classe, car ceci permet de décomposer simplement une instance en ses différentes parties :

 
Sélectionnez
1.
2.
3.
4.
5.
case class TypeScheme(tyvars: List[Tyvar], tpe: Type) {
    def newInstance: Type = {
        (emptySubst /: tyvars) ((s, tv) => s.extend(tv, newTyvar())) (tpe)
    }
}

Les objets schémas de types disposent d'une méthode newInstance qui renvoie le type contenu dans le schéma après que toutes les variables de types quantifiées universellement ont été renommées en nouvelles variables. L'implémentation de cette méthode accumule (avec /:) les variables de types du schéma avec une opération qui étend une substitution s donnée en renommant une variable de type tv donnée en nouvelle variable de type. La substitution ainsi obtenue renomme toutes les variables de types du schéma. Elle est ensuite appliquée à la partie type du schéma.

Le dernier type dont nous avons besoin dans l'inférenceur de type est Env, un type pour les environnements qui associe des noms de variables à des schémas de type. Il est représenté par l'alias de type Env dans le module typeInfer :

 
Sélectionnez
type Env = List[(String, TypeScheme)]

Il y a deux opérations possibles sur les environnements. La fonction lookup renvoie le schéma de type associé à un nom ou null si le nom n'est pas enregistré dans l'environnement :

 
Sélectionnez
1.
2.
3.
4.
def lookup(env: Env, x: String): TypeScheme = env match {
    case List() => null
    case (y, t) :: env1 => if (x == y) t else lookup(env1, x)
}

La fonction gen transforme un type donné en un schéma de type en quantifiant toutes les variables de types qui sont libres dans le type, mais pas dans l'environnement :

 
Sélectionnez
def gen(env: Env, t: Type): TypeScheme =
    TypeScheme(tyvars(t) diff tyvars(env), t)

L'ensemble des variables de types libres d'un type est simplement l'ensemble de toutes les variables de types qui apparaissent dans ce type. On le représente ici comme une liste de variables de types :

 
Sélectionnez
1.
2.
3.
4.
5.
def tyvars(t: Type): List[Tyvar] = t match {
    case tv @ Tyvar(a) => List(tv)
    case Arrow(t1, t2) => tyvars(t1) union tyvars(t2)
    case Tycon(k, ts) => (List[Tyvar]() /: ts) ((tvs, t) => tvs union tyvars(t))
}

La syntaxe tv @ … du premier motif introduit une variable qui est liée au motif qui suit l'arobase. Notez également que le paramètre de type explicite [Tyvar] dans l'expression de la troisième clause est nécessaire pour que l'inférence de type locale puisse fonctionner.

L'ensemble des variables de types libres d'un schéma de type est l'ensemble des variables de types libres de son composant type, exception faite des variables de types quantifiées :

 
Sélectionnez
def tyvars(ts: TypeScheme): List[Tyvar] =
 tyvars(ts.tpe) diff ts.tyvars

Enfin, l'ensemble des variables de types libres d'un environnement est l'union des variables de types libres de tous les schémas de types qui y sont enregistrés :

 
Sélectionnez
def tyvars(env: Env): List[Tyvar] =
    (List[Tyvar]() /: env) ((tvs, nt) => tvs union tyvars(nt._2))

Avec Hindley/Milner, l'unification est une opération essentielle de la vérification de type. L'unification calcule une substitution pour que deux types donnés soient égaux (cette substitution est appelée unificateur). La fonction mgu calcule l'unificateur le plus général de deux types t et u donnés sous une substitution s existante. Elle renvoie donc la substitution s' la plus générale qui étend s et qui est telle que les types s(t) et s(u) soient égaux :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
def mgu(t: Type, u: Type, s: Subst): Subst =
    (s(t), s(u)) match {
        case (Tyvar(a), Tyvar(b)) if (a == b) =>
            s
        case (Tyvar(a), _) if !(tyvars(u) contains a) =>
            s.extend(Tyvar(a), u)
        case (_, Tyvar(a)) =>
            mgu(u, t, s)
        case (Arrow(t1, t2), Arrow(u1, u2)) =>
            mgu(t1, u1, mgu(t2, u2, s))
        case (Tycon(k1, ts), Tycon(k2, us)) if (k1 == k2) =>
            (s /: (ts zip us)) ((s, tu) => mgu(tu._1, tu._2, s))
        case _ =>
            throw new TypeError("cannot unify " + s(t) + " with " + s(u))
    }

La fonction mgu lance une exception TypeError si aucune substitution unificatrice n'existe. Ceci peut arriver lorsque les deux types ont des constructeurs différents ou parce qu'une variable de type est unifiée avec un type qui contient la variable de type elle-même. Ces exceptions sont modélisées ici comme des instances des case classes qui héritent de la classe Exception prédéfinie.

 
Sélectionnez
case class TypeError(s: String) extends Exception(s) {}

La tâche principale du vérificateur de type est implémentée par la fonction tp qui prend en paramètres un environnement env, un terme e, un prototype t et une substitution existante s, et qui renvoie une substitution s' qui étend s et qui transforme s (env) e : s (t) en jugement de type dérivable selon les règles de dérivation du système de typage Hindley/Milner. Si cette substitution n'existe pas, la fonction déclenche l'exception TypeError :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
def tp(env: Env, e: Term, t: Type, s: Subst): Subst = {
    current = e
    e match {
        case Var(x) =>
            val u = lookup(env, x)
            if (u == null) throw new TypeError("undefined: " + x)
            else mgu(u.newInstance, t, s)

        case Lam(x, e1) =>
            val a, b = newTyvar()
            val s1 = mgu(t, Arrow(a, b), s)
            val env1 = {x, TypeScheme(List(), a)} :: env
            tp(env1, e1, b, s1)

        case App(e1, e2) =>
            val a = newTyvar()
            val s1 = tp(env, e1, Arrow(a, t), s)
            tp(env, e2, a, s1)

        case Let(x, e1, e2) =>
            val a = newTyvar()
            val s1 = tp(env, e1, a, s)
            tp({x, gen(env, s1(a))} :: env, e2, t, s1)
    }
}
var current: Term = null

Pour faciliter la détection des erreurs, la fonction tp stocke le sous-terme analysé dans la variable current : si la vérification de type échoue avec une exception TypeError, cette variable contiendra le sous-terme qui a posé problème.

La dernière fonction du module d'inférence de type, typeOf, est une façade simplifiée de tp. Elle calcule le type d'un terme e dans un environnement env. Pour ce faire, elle crée une nouvelle variable a, calcule une substitution de type qui transforme env e : a en jugement de type dérivable et renvoie le résultat de l'application de la substitution à a :

 
Sélectionnez
1.
2.
3.
4.
5.
    def typeOf(env: Env, e: Term): Type = {
        val a = newTyvar()
        tp(env, e, a, emptySubst)(a)
    }
} // fin de typeInfer

Pour appliquer l'inférenceur de type, il est pratique de disposer d'un environnement prédéfini contenant des liaisons pour les constantes souvent utilisées. Le module predefined définit par conséquent un environnement env qui contient des liaisons pour les types booléens, numériques et listes, ainsi que certaines opérations primitives définies sur ces types. Il définit également un opérateur de point fixe fix permettant de représenter la récursion :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
object predefined {
    val booleanType = Tycon("Boolean", List())
    val intType = Tycon("Int", List())
    def listType(t: Type) = Tycon("List", List(t))

    private def gen(t: Type): typeInfer.TypeScheme = typeInfer.gen(List(), t)
    private val a = typeInfer.newTyvar()
    val env = List(
        {"true", gen(booleanType)},
        {"false", gen(booleanType)},
        {"if", gen(Arrow(booleanType, Arrow(a, Arrow(a, a))))},
        {"zero", gen(intType)},
        {"succ", gen(Arrow(intType, intType))},
        {"nil", gen(listType(a))},
        {"cons", gen(Arrow(a, Arrow(listType(a), listType(a))))},
        {"isEmpty", gen(Arrow(listType(a), booleanType))},
        {"head", gen(Arrow(listType(a), a))},
        {"tail", gen(Arrow(listType(a), listType(a)))},
        {"fix", gen(Arrow(Arrow(a, a), a))}
    )
}

Voici un exemple d'utilisation de l'inférenceur de type. Définissons une fonction showType qui renvoie le type d'un terme donné, calculé dans l'environnement predefined.env :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
object testInfer {
    def showType(e: Term): String =
    try {
        typeInfer.typeOf(predefined.env, e).toString
    } catch {
        case typeInfer.TypeError(msg) =>
            "\n cannot type: " + typeInfer.current +
            "\n reason: " + msg
    }

L'application

 
Sélectionnez
> testInfer.showType(Lam("x", App(App(Var("cons"), Var("x")), Var("nil"))))

donnerait donc la réponse suivante :

 
Sélectionnez
> (a6->List[a6])

Exercice 16-0-1 : Étendez l'inférenceur de type Mini-ML avec une construction letrec permettant de définir des fonctions récursives et dont la syntaxe est :

 
Sélectionnez
letrec ident "=" term in term

Le typage de letrec est identique à let sauf que l'identificateur ainsi défini est visible dans l'expression. Avec letrec, la fonction length des listes peut être définie de la façon suivante :

 
Sélectionnez
1.
2.
3.
4.
5.
letrec length = \xs.
    if (isEmpty xs)
        zero
        (succ (length (tail xs)))
in ...

précédentsommairesuivant
Robin Milner. A Theory of Type Polymorphism in Programming. Journal of Computer and System Sciences, 17:348 -375, Dec 1978.
La classe hérite du type fonction sous la forme d'un mixin plutôt que comme une classe parente directe, car, dans l'implémentation actuelle de Scala, le type Function1 est une interface Java - il ne peut donc pas être utilisé comme classe parente d'une autre classe.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2017 Martin Odersky. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.