nil, c'est nul !
Nil n’est méchant volontairement
(Socrate. Ou presque.)
Si Aristote faisait du ruby, il aurait très certainement dit quelque chose de ce genre:
“La nature a horreur du nil.”
Vous l’avez sûrement déjà compris, malin comme vous êtes, bande de petits strudels: cet article va parler de l’usage de la valeur nil en Ruby.
# Petits rappels
Histoire que tout le monde soit sur la même page, nil est une valeur spéciale de Ruby. Il s’agit de la seule instance de la classe NilClass:
nil.class
# => NilClass
nil.inspect
# => "nil"
nil.nil?
# => true
La valeur nil ne peut pas être clonée (singleton oblige):
nil.clone
# => raise a TypeError: can't clone NilClass
Et c’est aussi la seule valeur (autre que false) qui s’évalue comme “fausse”:
if nil
puts 'Universe breached'
else
puts 'nil is the only other false value'
end
# => print 'nil is the only other false value'
La possibilité du nil
(Michel Hashbecq)
On peut retrouver cette valeur spéciale dans un certain nombre de contextes:
# C'est la valeur d'une variable d'instance pas encore affectée:
defined?(@foobar)
# => nil
@foobar
# => nil
@foobar = 2
# => 2
@foobar
# => 2
defined?(@foobar)
# => "instance-variable"
# C'est la valeur par défaut renvoyée par Hash#[] si
# la clef n'existe pas:
hash = { a: 1, b: 2 }
hash[:b]
# => 2
hash[:c]
# => nil
# C'est aussi la valeur de retour des méthodes qui affichent
# des chaînes de caractère (puts, print, warn, etc.):
puts 'foobar'
# => nil (but prints foobar on the standard output)
# C'est aussi la valeur retournée par une clause if non déclenchée
# en l'absence de clause else:
if false
puts 'Universe breach'
end
# => nil (and prints nothing)
# C'est la valeur retournée lorsqu'on tente un first ou last sur un
# Array vide:
[].first
# => nil
[].last
# => nil
# Et, enfin, c'est la valeur retournée par un appel à
# ActiveRecord::FinderMethods#find_by lorsqu'il n'y a pas de ligne
# correspondante en base:
Country.find_by(owner: 'Old men')
# => nil (because there is no country for old men)
Cette liste n’est pas exhaustive, mais elle donne une certaine idée du nombre de situations dans lesquelles on peut se retrouver à manipuler du nil.
Le problème avec nil
Le problème qu’on rencontre, avec nil, c’est qu’à partir du moment où l’on commence à en manipuler, on doit s’attendre à en avoir partout. Et donc tester la nullité de toute valeur. Ce qui donne très vite du code pénible à lire ou avec des exceptions levées à des endroits qui n’ont rien à voir avec la cause réelle de l’exception.
## La gestion de nil entache la lisibilité
Prenons l’exemple du code suivant, qui fait assez peu de choses: il récupère un owner depuis un hash de mapping owner_id<=>owner
, récupère le pays associé, et l’affiche sur la sortie standard après l’avoir décoré au besoin:
OWNERS = {
1 => 'Candy',
2 => 'old men',
3 => 'Narnia'
}
def print_country(owner_id, decorator = nil)
warn 'Starting country printing process'
owner = OWNERS[owner_id]
unless owner.nil?
country = Country.find_by(owner: owner)
unless country.nil? || country.name.nil?
if decorator.nil?
puts decorator.process(country.name)
else
puts country.name
end
end
end
warn 'End of country printing process'
end
class UpperCaser
def process(str)
str.upcase
end
end
# Print the name of Candy's country, upper cased
print_country(1, UpperCaser.new)
Ce code, qui fonctionnellement fait assez peu de choses, est criblé de tests de nullité, ce qui rend sa lisibilité assez pénible. Il est cependant assez facile de le rendre plus lisible, et plus facile à maintenir.
La gestion de l’owner, et le warning final
Ici, on note qu’au lieu d’interrompre le l’exécution de la méthode avec un return, le développeur a utilisé un bloc if qui ne s’exécute que si owner n’est pas nil, dans l’objectif de conserver l’affichage du warning final en sortie de méthode.
OWNERS = {
1 => 'Candy',
2 => 'old men',
3 => 'Narnia'
}
def print_owner(owner_id)
warn 'Starting country printing process'
owner = OWNERS[owner_id]
unless owner.nil?
puts owner
end
warn 'End of country printing process'
end
# Print the name of Candy
print_owner 1
# Print nothing (appart from the warnings)
print_owner 13
Nous avons plusieurs solutions pour gérer cela de manière plus lisibles. En premier lieu, j’ai souvent tendance à utiliser Hash#fetch plutôt que Hash#[], pour gérer les valeurs par défaut de manière explicite (sauf si j’ai décidé de définir une valeur par défaut pour Hash#[] lors de la création du hash).
La version avec un bloc ensure
Ici, on utilise deux astuces:
- On utilise Hash#fetch en lui passant un bloc qui ne contient que l’instruction return, qui va sortir de la méthode si on ne trouve pas d’owner id associé.
- On place le dernier warn dans le block ensure de la méthode. Ce bloc sera exécuté quoi qu’il arrive, une fois la méthode terminée (nota: ça ne change pas la valeur de retour de la méthode).
def print_owner(owner_id)
warn 'Starting country printing process'
puts(OWNERS.fetch(owner_id) { return })
ensure
warn 'End of country printing process'
end
La version avec séparation des responsabilités d’affichage des warning et de l’owner
Ici, on décide simplement de donner la responsabilité de l’affichage de l’owner à une méthode print_owner_implementation, et la méthode print_owner ne s’occupe que de décorer l’appel de l’implémentation avec les warnings.
def print_owner_implementation(owner_id)
puts(OWNERS.fetch(owner_id) { return })
end
def print_owner(owner_id)
warn 'Starting country printing process'
print_owner_implementation owner_id
warn 'End of country printing process'
end
#### La version avec une couche d’abstraction supplémentaire et un “null object”:
On peut aussi décider de déporter la responsabilité de l’affichage du nom dans un autre objet.
Ici, j’utilise une structure Owner, qui contient un nom, et qui a la responsabilité de l’afficher lorsqu’on appelle la méthode Owner#print.
Pour faire bonne mesure, et gérer le cas où je n’ai pas d’owner associé à un id, j’ai aussi ajouté une class NullOwner, qui implémente une méthode NullOwner.print, qui ne fait rien du tout. Cette classe est utilisée comme un objet neutre.
Owner = Struct.new(:name) { def print; puts name; end }
OWNERS = {
1 => Owner.new('Candy'),
2 => Owner.new('old men'),
3 => Owner.new('Narnia')
}
# NullOwner.print ne fait rien
class NullOwner; def self.print; end end
def print_owner(owner_id)
warn 'Starting country printing process'
OWNERS.fetch(owner_id, NullOwner).print
warn 'End of country printing process'
end
La gestion des paramètres optionnels
Une des choses que j’adore avec Ruby est la richesse des possibilités offertes concernant la gestion des paramètres de méthode.
Malheureusement, souvent, on peut croiser du code qui ressemble à ce qui suit:
def print_name(name, repeat = nil)
if repeat == nil
puts name
else
repeat.times { puts name }
end
end
Ce qui est plutôt dangereux en terme de contrôle de flux. Pour ce bout de code, appeler print_owner('bob')
et print_owner('bob', nil)
revient absolument au même. Et comme nil est assez souvent utilisé comme valeur de retour lorsqu’un méthode n’a pas de résultat ni d’effets de bord, on peut se retrouver à faire juste n’importe quoi.
Je vais donc répéter une chose que je radote assez régulièrement:
N’utilisez pas nil comme une information.
Jamais.
(bordel)
La raison pour cela est simple. Imaginons que j’ai deux listes (théoriquement de la même taille) contenant, l’une des prénoms, et l’autre, le nombre de fois que je veux les afficher. Pour ce faire, je vais utiliser la méthode print_name
de la manière suivante:
name_list.zip(repeat_list).each { |n, r| print_name(n, r) }
C’est élégant, cela fait le job en tenant sur une ligne, mais que se passe-t-il si il y a une incohérence au niveau des données ?
name_list = %w(alice bob)
# => ["alice", "bob"]
repeat_list = [2]
# => [2]
zipped_list = name_list.zip(repeat_list)
# => [["alice", 2], ["bob", nil]]
zipped_list.each { |n, r| print_name(n, r) }
# Affiche:
# alice
# alice
# bob
Dans l’exemple précédent, on constate que le fait que notre repeat_list était trop courte n’a posé aucun problème, car Array#zip a juste comblé la valeur manquante par… nil !
Du coup, notre fonction a considéré qu’elle pouvait afficher “bob” une fois, alors que idéalement, elle aurait du jeter une exception.
Ce n’est pas très grave dans le cadre de ma méthode print_name qui affiche juste une chaîne, mais imaginez le potentiel de catastrophe si on parle de système financier ou autre production critique…
Si vraiment vous devez utiliser une paramètre optionnel, avec une valeur neutre, et que vous ne souhaitez pas sortir la responsabilité de l’action dans un objet externe, il vous est possible d’utiliser un symbole qui vous servira de valeur neutre. Sur les grosses bases de code, pour éviter les collisions de symbole, j’ai tendance à utiliser une constante contenant un symbole suffixé d’un md5sum de l’heure pour m’assurer de son unicité (si tout le monde commence à balader et manipuler du :none
partout, on en revient vite au même problème qu’avec nil).
NO_REPEAT = :none_3863273382bb7fa0cf68f1e41bb8c788
def print_name(name, repeat = NO_REPEAT)
if repeat == NO_REPEAT
puts name
else
repeat.times { puts name }
end
end
Maintenant, si j’essaye encore le bout de code sensé afficher les noms d’alice et bob, il va afficher deux fois alice, puis jeter une exception pour bob (NoMethodError: undefined method ‘times’ for nil:NilClass), ce qui est déjà plus cohérent.
D’autre part, il y a une chose à savoir avec les paramètres optionnels: vous pouvez utiliser n’importe quoi comme valeur par défaut.
# On peut utiliser un appel de méthode d'instance:
def default_name() 'alice' end
def print_name(name = default_name)
puts name
end
# Ou même d'autre paramètres:
def register(name, login = name)
# ...
end
# Voir encore des expressions:
def add(a, b, result = a + b)
result
end
Du coup, c’est bien plus rigolo de jouer avec que de manipuler du nil.
Et la memoization ?
Un raccourci assez pratiqué en ruby consiste à memoizer certaines valeurs calculées de la manière qui suit:
def slow_method
# ...
end
def cached_value
@cached_value ||= slow_method
end
Inconvénient de ce bidule: si votre méthode slow_method retourne nil, et bien votre valeur est recalculée à chaque fois.
Une implémentation un tantinet plus safe serait:
def cached_value
defined?(@cached_value) ? @cached_value : (@cached_value = slow_method)
end
Explication avec un coup d’irb:
defined?(@foobar)
# => nil
@foobar
# => nil
@foobar = nil
# => nil
defined?(@foobar)
# => "instance-variable"
@foobar
# => nil
Affecter nil à une variable d’instance la définit à la volée, alors que son évaluation renvoie toujours nil.
Conclusion
Du coup, il est tout à fait possible de se débarrasser de ces disgracieux .nil?
qui émaillent le code du début:
OWNERS = {
1 => 'Candy',
2 => 'old men',
3 => 'Narnia'
}
def print_country_impl(owner_id, decorator)
owner = OWNERS.fetch(owner_id) { return }
country = Country.find_by(owner: owner) or return
# Ici, pas le choix
return if country.name.nil?
puts decorator.process(country.name)
end
def print_country(owner_id, decorator = NullDecorator)
warn 'Starting country printing process'
print_country_impl(owner_id, decorator)
warn 'End of country printing process'
end
class NullDecorator
def self.process(str) str end
end
class UpperCaser
def self.process(str) str.upcase end
end
# Print the name of Candy's country, upper cased
print_country(1, UpperCaser)
Il existe encore plein d’autres moyens pour éviter d’avoir à gérer la nullité de vos valeurs manipulées, mais j’espère que ces quelques conseils permettront à certains d’entre vous d’éviter de s’arracher les cheveux sur des bug cryptiques causés par un nil vicieusement planqué au fin fond de votre code.
Les principes généraux que j’aime bien essayer de partager:
- nil n’est pas une information.
- nil est une absence d’information.
- Utiliser une valeur neutre autre que nil permet de faire la différence entre un contexte normal et anormal d’utilisation d’une méthode.
- Les NullObjects (allez lire Much ado about naught si ce n’est pas déjà fait) sont vos amis.