1. Pourquoi une diffusion naive casse dans un programme reel
Quand vous ecrivez emit, il est tres facile de tomber dans le piege « ce n’est qu’une liste, je vais juste la parcourir ». Et dans les premiers exercices, c’est meme vrai. Mais des que votre code devient ne serait-ce qu’un peu vivant, les handlers commencent a se comporter comme de vraies personnes dans une file : quelqu’un s’en va, quelqu’un amene soudain un ami, et quelqu’un s’evanouit (exception) — et la file doit continuer d’avancer.
Imaginons une implementation naive : elle sait deja gerer l’abonnement et le desabonnement, mais la diffusion reste « fragile ».
class Event<T> {
private val listeners = mutableListOf<(T) -> Unit>()
fun subscribe(listener: (T) -> Unit): () -> Unit {
listeners += listener
return { listeners.remove(listener) }
}
fun emit(value: T) {
for (l in listeners) l(value)
}
}
En apparence, tout est propre. Mais ajoutons maintenant deux scenarios « ordinaires » de la vraie vie.
Scenario : se desabonner a l’interieur d’un handler
Supposons qu’un handler soit « a usage unique » : apres le premier evenement, il se desabonne.
fun main() {
val e = Event<Int>()
var unsubscribe: (() -> Unit)? = null
unsubscribe = e.subscribe { x ->
println("one-time got $x") // one-time got 1
unsubscribe?.invoke()
}
e.emit(1)
e.emit(2)
}
Ici, meme sans entrer dans les details, on voit que pendant emit la liste des listeners change. Sur la JVM, ce genre de choses se termine souvent mal (du style ConcurrentModificationException) ou par des bugs logiques « silencieux » : quelqu’un a ete saute, quelqu’un a ete appele une fois de trop.
Scenario : un handler plante, les autres doivent survivre
Imaginons que vous ayez un audit log et des notifications. Les notifications plantent (par exemple a cause d’une erreur de formatage), mais l’audit doit toujours etre enregistre.
Si emit n’est pas protege, l’exception interrompt la boucle et les autres handlers ne seront pas appeles. Et la, on n’est plus dans « oups, dommage », mais plutot dans « pourquoi nos rapports ne correspondent pas ? ».
2. Snapshot des listeners : copier la liste avant la diffusion
Si vous avez deja photographie un chat qui court partout dans la piece, vous comprenez deja l’idee de snapshot : vous prenez une photo « voila comment c’etait a cet instant », et ensuite le chat peut courir comme il veut — la photo ne change pas. Dans la diffusion d’evenements, le snapshot sert exactement a cela : on fige quel ensemble de listeners recevra la diffusion courante, et les changements d’abonnements pendant la diffusion n’affectent que les futurs emit.
L’outil cle de Kotlin ici est toList(). Il cree une copie de la liste d’elements a un instant donne ; les modifications ulterieures de la MutableList d’origine n’affectent pas la copie.
Mauvais parcours : liste « vivante »
fun emitUnsafe(value: T) {
for (l in listeners) l(value)
}
Le probleme n’est pas que le code soit « mauvais » en termes de style. Le probleme, c’est qu’il ne tient pas face a la realite, ou un handler peut modifier la liste.
Bon parcours : snapshot en premiere etape
fun emit(value: T) {
val snapshot = listeners.toList()
for (l in snapshot) l(value)
}
Et voici une regle tres importante (presque comme « mettez d’abord votre masque a oxygene ») : le snapshot doit etre la toute premiere action dans emit. Si vous commencez la diffusion, puis decidez soudain de faire un snapshot « au milieu », vous obtiendrez un comportement impossible a expliquer a quelqu’un sans recourir a des rituels chamaniques.
Contrat de l’approche snapshot
Avec une diffusion basee sur snapshot, nous « signons » en quelque sorte un contrat avec les utilisateurs de notre Event<T>.
- Si un handler s’abonne pendant emit, il commencera a recevoir des evenements uniquement a partir du emit suivant.
- Si un handler se desabonne pendant emit, il peut quand meme recevoir l’evenement courant (parce qu’il est deja dans le snapshot), mais ne recevra pas les suivants.
Ce contrat est previsible, simple, et s’explique parfaitement en un paragraphe. Dans les projets pedagogiques, c’est presque toujours le meilleur choix.
3. try/catch autour de chaque handler
Avec les exceptions dans les handlers, il y a une nuance : on veut que la diffusion soit « resiliente », mais on ne veut pas transformer les erreurs en « trou noir » ou rien n’est visible et personne n’est responsable. La bonne strategie est donc souvent la suivante : on intercepte l’exception pour chaque listener individuellement, on enregistre le fait qu’il y a eu une erreur (au moins un println), puis on continue la diffusion.
Pourquoi exactement comme ca ? Parce qu’un try/catch autour de toute la diffusion attrape l’erreur, mais arretera la boucle. Autrement dit, un listener cassera les autres. Alors que si on enveloppe chacun, l’erreur est isolee.
Mauvaise protection : un seul try/catch pour tout
fun emit(value: T) {
try {
val snapshot = listeners.toList()
for (l in snapshot) l(value)
} catch (e: Exception) {
println("Emit failed: ${e.message}")
}
}
Cela a l’air « protege », mais en pratique : si le premier handler plante, le deuxieme et le troisieme ne seront deja plus appeles.
Bonne protection : try/catch pour chacun
fun emit(value: T) {
val snapshot = listeners.toList()
for (l in snapshot) {
try {
l(value)
} catch (e: Exception) {
println("Listener failed: ${e.message}")
}
}
}
Oui, cela fait un peu plus de lignes. Mais en echange, vous achetez de la previsibilite et de la resilence du systeme.
4. Reentrance : un handler peut rappeler emit
Le mot « reentrance » sonne comme si nous allions construire un vaisseau spatial. En pratique, le sens est simple : pendant le traitement d’un evenement, il peut arriver qu’on rappelle emit — directement ou indirectement.
Par exemple : vous ajoutez une depense → un handler decide que le montant est trop eleve → il ajoute automatiquement une « commission » comme depense separee → l’ajout de la commission declenche lui aussi l’evenement « depense ajoutee ». Boum : diffusion imbriquee.
Il est important que ce scenario ne transforme pas le programme en chaos. L’approche snapshot donne deja une base : chaque emit prend sa propre copie des listeners. Mais il faut bien comprendre quels effets sont possibles et quels pieges restent.
Exemple de reentrance : emit imbrique
fun main() {
val e = Event<Int>()
e.subscribe { x ->
if (x == 1) e.emit(2)
println("A got $x")
}
e.subscribe { x -> println("B got $x") }
e.emit(1)
}
Un des affichages possibles serait le suivant (l’ordre des lignes depend de l’endroit ou vous imprimez) :
A got 2
B got 2
A got 1
B got 1
Et c’est normal : vous avez vraiment lance emit(2) a l’interieur de emit(1). Donc l’ordre devient « imbrique », comme des appels de fonctions. C’est exactement ce que signifie « diffusion synchrone ».
Ce que snapshot garantit en cas de reentrance
Snapshot garantit que meme si, a l’interieur de emit(1), un handler s’abonne ou se desabonne, cela ne cassera pas le parcours en cours. Chaque emit travaille avec sa propre copie.
Ce que snapshot ne resout pas
Snapshot ne vous protege pas d’une recursion infinie. Si un handler, pour n’importe quelle valeur, rappelle emit sans s’arreter, vous obtiendrez un depassement de pile. Ce n’est pas un bug de Event<T>, c’est la logique du handler.
Donc, dans le cadre de la lecon d’aujourd’hui, nous fixons un contrat simple et clair : emit est synchrone et accepte des appels imbriques, et la responsabilite de « ne pas partir a l’infini » incombe aux handlers.
5. Assemblons un Event<T> fiable
Assemblons maintenant soigneusement la version finale de Event<T>, utilisable dans une application pedagogique. Nous continuons l’idee d’un tracker de depenses en console : vous tapez « cafe 4.5 », et l’application affiche « encore du cafe, courage ».
Modele d’evenement : « depense ajoutee »
Pour simplifier, creons un petit modele :
data class ExpenseAdded(
val id: Int,
val title: String,
val amount: Double
)
Event<T> final
Faisons en sorte que subscribe renvoie un jeton de desabonnement ; de plus, un desabonnement repete sera sans danger (ne plantera pas et n’essaiera pas de « supprimer deux fois »).
class Event<T> {
private val listeners = mutableListOf<(T) -> Unit>()
fun subscribe(listener: (T) -> Unit): () -> Unit {
listeners += listener
var active = true
return {
if (active) {
listeners.remove(listener)
active = false
}
}
}
fun emit(value: T) {
val snapshot = listeners.toList()
for (l in snapshot) {
try {
l(value)
} catch (e: Exception) {
println("Listener failed: ${e.message}")
}
}
}
}
Ici, le snapshot est fait via toList(), ce qui nous donne une « photo » des listeners au moment du debut de la diffusion.
try/catch entoure chaque listener, afin qu’une panne ne coupe pas la diffusion pour tous les autres.
Integration de l’evenement dans un « mini-tracker »
Supposons que nous ayons un service qui ajoute une depense et emet un evenement :
class ExpenseService {
private var nextId = 1
val expenseAdded = Event<ExpenseAdded>()
fun addExpense(title: String, amount: Double) {
val e = ExpenseAdded(nextId++, title, amount)
expenseAdded.emit(e)
}
}
Deux listeners independants : audit et « notifications »
fun main() {
val service = ExpenseService()
service.expenseAdded.subscribe { e ->
println("AUDIT: added #${e.id} '${e.title}'") // AUDIT: added #1 'Coffee'
}
service.expenseAdded.subscribe { e ->
if (e.amount > 1000) error("Too big!") // peut planter
println("UI: +${e.amount} for ${e.title}") // UI: +4.5 for Coffee
}
service.addExpense("Coffee", 4.5)
service.addExpense("Laptop", 2000.0)
service.addExpense("Sandwich", 6.0)
}
La logique attendue est la suivante : « Laptop » fera planter le deuxieme handler, mais l’audit doit continuer a fonctionner, et la troisieme depense doit aussi etre traitee.
Exemple d’un affichage possible :
AUDIT: added #1 'Coffee'
UI: +4.5 for Coffee
AUDIT: added #2 'Laptop'
Listener failed: Too big!
AUDIT: added #3 'Sandwich'
UI: +6.0 for Sandwich
Remarquez le comportement : l’exception ne s’est pas « collee » a tout emit, elle est isolee a l’interieur d’un seul listener.
Schema : ce qui se passe a l’interieur de emit
Parfois, il est utile de voir non seulement le code, mais aussi le « schema du processus ». Representons la diffusion comme une chaine de production :
flowchart TD
A["emit(value) appele"] --> B["snapshot = listeners.toList()"]
B --> C["for listener in snapshot"]
C --> D["try { listener(value) }"]
D --> E["succes → listener suivant"]
D --> F["catch Exception → on journalise l’erreur"]
F --> E
E --> G["fin de la diffusion"]
Le message du diagramme tient en une phrase : on fige d’abord la liste des listeners, puis on parcourt la copie en isolant les erreurs de chaque handler.
6. Erreurs typiques
Erreur n°1 : faire le snapshot pas au debut de emit.
On rencontre parfois l’approche « je vais d’abord appeler quelques listeners, puis je ferai une copie ». C’est presque garanti que cela produira un comportement etrange : une partie des listeners sera appelee selon l’ancienne liste, une autre selon la nouvelle. Si vous choisissez la strategie snapshot, la photo doit etre la premiere etape de la diffusion, sinon vous perdez l’avantage principal : la previsibilite.
Erreur n°2 : envelopper tout emit dans un seul try/catch.
Ce code a l’air « fiable », mais il ne protege pas ce qu’il faut. En cas d’exception, le premier handler qui plante interrompra la boucle, et les autres listeners ne recevront pas l’evenement. Le bon principe ici est l’isolation : try/catch doit entourer chaque handler, afin qu’une panne ne casse pas tout le processus.
Erreur n°3 : « avaler » les exceptions en silence.
Si vous interceptez une exception et ne faites rien (meme pas afficher un message), vous vous fabriquez une enigme tres couteuse pour plus tard : « pourquoi un des modules a cesse de reagir ? ». Dans du code pedagogique, un println suffit ; dans du code plus serieux, il faut au moins enregistrer le message et, si possible, la stack trace. L’essentiel est de ne pas rendre les erreurs invisibles.
Erreur n°4 : s’attendre a ce qu’un desabonnement dans emit annule instantanement l’appel dans la diffusion courante.
Avec l’approche snapshot, ce n’est pas le cas : la diffusion courante parcourt deja une copie. Donc un handler peut recevoir l’evenement « encore une fois » dans le cadre du emit courant, meme s’il vient juste de se desabonner. Ce n’est pas un bug, c’est une partie du contrat. L’avantage est que le contrat est simple et stable : les changements d’abonnement n’affectent que les diffusions suivantes.
Erreur n°5 : declencher par accident une reentrance infinie.
Si un handler, sur n’importe quel evenement, rappelle emit sans condition d’arret, vous obtenez une recursion puis, au final, un depassement de pile. Snapshot n’a pas a (et ne peut pas) corriger cela : c’est une erreur logique du handler. Donc, pour les scenarios reentrants, gardez toujours en tete une « condition de sortie » : une verification de valeur, un drapeau, une limite, ou au moins du bon sens.
GO TO FULL VERSION