samedi 7 juin 2008

Ruby Duck-Safe interface

Suite à un précédent article sur DiamondBack Ruby, j'ai un peu réfléchis à la question de la sécurisation de l'appel de fonctions/procédures/méthodes, au niveau des types dans un langage dynamique tel que Ruby (/Python,/Perl/Etc...).

Finalement, quel est le problème ? Quelque soit le paradigme, il se situe au niveau de la capacité d'une fonction/procédure/méthode à traiter ses arguments en fonction de leur type, classe ou comportement. Problème simple donc. (Les aspects plus bas niveau sont ici peu intéressants : on est actuellement capables de les éluder).

Tout est dans la notion d'interface : dans les langages statiques, une interface n'est pas seulement un moyen d'appeler un "service" (fonction/méthode/...) mais c'est également un "contrat" qui impose des conditions sur les informations passées à l'appel (le plus souvent sur leur type). Par exemple, la fonction acheter_une_baguette ne peut se contenter de prendre 0.45 en argument : il faut que 0.45 soit en €, "de type €".

Ruby est un langage dynamique comme tant d'autres. Mais de par sa conception, sa "philosophie", il fait le choix de se concentrer sur ce que fait un objet plutôt que sur ce qu'il est : c'est le Duck Typing. On peut facilement critiquer cette approche du typage pour plusieurs raisons. Cet article sur Otaku en expose plusieurs. Pour ma part, je me contenterais de dire qu'elle réside dangereusement sur la langue (quel rapport entre Balançoire.balancer et Coupable.balancer ?).

Mais les langages statiques ne sont pas nécessairement satisfaisants sur ce point non plus. Pour faire un parallèle avec les mathématiques, on peut considérer que :

f : R -> R
x |-> x + 2

avec R l'ensemble des réels, est une déclaration de type statique.

Pour autant, f reste valable de N dans N (naturels), de C dans C (complexes), etc... Le Duck Typing assure que f est valable du moment que x possède une méthode "+", ce qui évite de redéfinir f pour chaque espace où cette fonction est valable.

Mais est-ce suffisant ? Prenons :

f : x |-> 2 / x

Dans ce cas, on doit de plus assurer que x n'est pas nul. Si mathématiquement, cela s'exprime sur l'espace sur lequel est défini x, "informatiquement" on aura le plus souvent x entier ou flottant. Les contraintes sur un argument ne se limite donc pas à son type/classe, ni à son comportement (possède méthode "+"), mais aussi à d'autres paramètres, comme son état (ici sa valeur). De plus, s'il s'agit ici d'un exemple simple : il existe probablement d'autres exemples mettant en évidence l'influence du contexte d'appel.

Je vous propose ici un module (naïf) permettant d'exprimer assez simplement, lors de l'implémentation d'une méthode, les contraintes associées à ses arguments. Le but est d'assurer ces contraintes sans pour autant surcharger le code de structures conditionnelles avec gestion d'exceptions : on déclare simplement notre contrat.

(sources également disponibles **sans fuck1n' coupure** ICI)

module Safety

# Negator for error message
@@neg_converter = {:is_a? => "is not a",
:respond_to? => "does not respond to",
:include? => "does not include",
:each_element_is_a? => "contains element(s) that is(are) not"}

# Ensure that constraints are respected
def ensure_it cstr
cstr.each do |arg, cstrs|
value = cstrs[0]
cstrs[1...cstrs.size].each do |pair|
if not value.method(pair[0]).call(pair[1]) then
raise "Argument #{arg} (#{value.inspect}) \
#{@@neg_converter[pair[0].to_sym]} #{pair[1]}."
end
end
end
end

private

# A simple example of constraint method
def each_element_is_a? klass
assertion = true
self.each do |elt|
assertion = false if not elt.is_a? klass
end
assertion
end

end


La méthode ensure_it checke les contraintes passées. Une contrainte est facilement exprimable par une méthode telle que each_element_is_a?.

Exemples :


require 'Safety'

class Fixnum
include Safety
def mult_by_plus num1, num2
ensure_it({:num1 => [num1, [:is_a?, Fixnum]],
:num2 => [num2, [:respond_to?, :next]]})
self*num1+num2
end

end

a = 1
puts a.mult_by_plus(1,2)
puts a.mult_by_plus(1,1)
puts a.mult_by_plus(1,1.0) #=> Error raised


Et :


require 'Safety'

include Safety

def potamok tab
ensure_it({:tab => [tab, [:respond_to?, :each],
[:each_element_is_a?, Fixnum]]})
val = 0
tab.each do |elt|
val += elt**2
end
val
end

puts potamok [42,33,59]
puts potamok [1,2,3,6,59.3] #=> Error raised


Toute remarque est la bienvenue.

4 commentaires:

Lefty a dit…

toute remarque étant la bienvenue, j'en fais une:

"I've got absolutely no idea of what you're talking about, buddy"

Ça ne veut pas dire grand chose, mais ça m'a paru approprié dans les circonstances.

Anonyme a dit…

> Pour autant, f reste valable de
> N dans N (naturels), de C dans C
> (complexes), etc...

Non. Mathématiquement, f : N -> N et f : C -> C sont deux fonctions différentes

Par ailleurs, on peut aussi simuler la situation que tu décris dans un langage statique.

En Haskell, on utiliserait les type classes :

f :: Num a => a -> a

Ça veut dire "f prend et renvoie un type instance de la classe des types numériques".

En OCaml, tu peux utiliser une solution équivalente aux type classes, qui repose sur des foncteurs :

module type NOMBRES = sig
type t
val (+) : t -> t -> t
val of_int : int -> t
end

module Truc (Nombres : NOMBRES) = struct
let f x = Nombres.(+) x (Nombres.of_int 2)
end

(Cette solution a l'air plus lourde parce que j'ai redéfini NOMBRES, en Haskell "Num" existe déjà, et ça manque de sucre syntaxique pour to_int, mais en pratique elles sont très semblables).

On peut aussi utiliser une solution orientée objet :

# let f x = x#add 2;;
val f : < add : int -> 'a; .. > -> 'a

Le compilateur nous dit : cette fonction prend en paramètre tout objet qui a au moins une méthode "add", qui prend un entier en paramètre (c'est même un peu plus général que les types que tu proposes parce que le résultat de add n'est pas forcément du type de départ).

C'est très proche de la partie du "Duck Typing" que tu utilises, mais c'est du typage statique.

Sobe a dit…

Hummm... Effectivement, à la relecture, je m'aperçois que je me suis un peu enflammé sur la partie mathématique... La fonction f n'est mathématiquement bien sûr pas la même si l'on change son domaine de définition.
Cela dit, si l'on définit f de C dans C, elle reste mathématiquement valable de N dans C, de R dans C, etc étant donné que C inclut N et R. On retombe donc du coup sur des problèmes plus proches du polymorphisme que du typage apparemment.

Merci pour tes précisions en Haskell et OCaml : elles montrent bien la puissance de ces langages sur les questions de typage (et accessoirement leur "joli" côté matheux). La dernière méthode me semble particulièrement élégante !

Quid des langages statiques moins "modernes" (je pense à des choses comme Ada95 ou Fortran... non pas Fortran) où ce genre de choses est nettement moins évident à réaliser ?
Et enfin, quelle(s) méthode(s) employer avec les langages dynamiques pour sécuriser ses interfaces "critiques" sur ce type de schéma, en sabotant le moins possible leurs atouts (rapidité de développement, flexibilité, etc...) ?

Le problème me paraît intéressant en soi.

Anonyme a dit…

Les langages dynamiques à objets que sont Python et Ruby (ou ObjC, ou Smalltalk ?) sont susceptibles de répondre à de nouvelles méthodes n'importe quand. Impossible donc de prouver à l'avance que les choses vont fonctionner, sauf si tu réussis à délimiter les "zones" de ton programme dans lesquelles ces méthodes risquent d'être modifiées. Du coup, t'as un surcoût permanent dû à la vérification des types. Joyeux...

En ObjC t'as bien un compilateur qui te dit que ton objet pourrait ne pas répondre à ton message lors de l'exécution. Ce qui est proprement pathétique.