JavaRush/Courses/Kotlin SELF/Unsubscribing without pain — an unsubscribe token and the...

Unsubscribing without pain — an unsubscribe token and the “reference” nature of lambdas

Open

1. Why unsubscribing is needed

When you write events for the first time, you get the feeling: “I subscribed — and that’s it, let it run forever.” That feeling lasts exactly until the moment when you “temporarily enabled logging” a couple of times, and then get surprised that it prints three times. Or when a “temporary handler” keeps living even though the scenario has already ended. Unsubscribing is not an architect’s whim; it’s a way to keep program behaviour predictable and to avoid accumulating unnecessary reactions.

Let’s imagine our learning console project (say it’s an expense tracker) where a service publishes an “expense added” event, and different parts of the system react: audit writes to the log, the UI prints a message, and statistics updates counters.

data class Expense(val title: String, val amount: Int)

class ExpenseService {
    val expenseAdded = Event<Expense>()

    fun addExpense(title: String, amount: Int) {
        val e = Expense(title, amount)
        println("Added: $e") // Added: Expense(title=Coffee, amount=250)
        expenseAdded.emit(e)
    }
}

So far, so good. But then “life” starts: we want to turn audit on/off, console messages are not always needed either, and a test handler should be temporary altogether. That means we need proper unsubscribing.

2. Why “just remove the listener” is harder than it seems

When we have a list, it seems logical: “I’ll just remove it from the list — and that’s it.” The problem is that a listener is a function, and in Kotlin functions can be stored as values (lambdas, function references). This is a separate superpower of the language: functions can be put into variables and collections, passed as parameters, and returned from functions.

But how do you remove a function from a list? You need to know exactly which function you added. And here we hit a classic beginner trap: “I’ll just write the same lambda again.”

Here’s what a “naive attempt” looks like:

val event = Event<String>()

fun main() {
    event.subscribe { println("Got: $it") }

    // We try to "unsubscribe" with a similar lambda…
    // But our API doesn't even have such a function, and that's not an accident.
}

Even if we added a method unsubscribe(listener), you would very quickly learn that the “same lambda by text” is not the “same lambda by reference.” In programming it’s like trying to return to a store “exactly the same” T-shirt, but not the one you bought: it looks similar, but the receipt doesn’t match.

3. Functions as values and the “reference-ness” of lambdas

What exactly we put into listeners

In Kotlin, a function has a type. For example, a handler that takes an Expense and returns nothing has the type (Expense) -> Unit. This is called a function type; Kotlin explicitly uses this notation to describe “functions as values.”

Let’s “ground” this with a simple example so your brain doesn’t try to run away on vacation:

fun main() {
    val printer: (String) -> Unit = { text ->
        println("PRINT: $text")
    }

    printer("Hello") // PRINT: Hello
}

Here printer is an ordinary variable; it just contains a “piece of code” inside. And when we do subscribe(listener), we are effectively putting such values into a list:

class Event<T> {
    private val listeners = mutableListOf<(T) -> Unit>()

    fun subscribe(listener: (T) -> Unit) {
        listeners += listener
    }

    fun emit(value: T) {
        for (l in listeners) l(value)
    }
}

The key detail: the list contains concrete function objects, and to remove an element you need a reference to that same object.

Why the “same” lambda is not the same

Now for an important point that saves a lot of nerves and coffee (or creates a reason for coffee — depends on how you feel about it).

Two lambdas that look identical are usually two different objects:

fun main() {
    val a: () -> Unit = { println("Hi") }
    val b: () -> Unit = { println("Hi") }

    println(a == b)   // false
    println(a === b)  // false
}

Why is that? Because in most cases a lambda is an object, and a new lambda literal is a new object. If you want to remove “that same handler,” you need a reference to it: the same variable, the same object.

fun main() {
    val a: () -> Unit = { println("Hi") }
    val c = a

    println(a === c) // true
}

The conclusion is simple and a bit sad: to unsubscribe, you either have to store a reference to the handler somewhere, or design an API that gives you a way to unsubscribe without requiring “guess which listener I added an hour ago.”

And this is where our hero appears: the unsubscribe token.

4. The unsubscribe token and the subscribe contract

The token idea

The idea of an unsubscribe token sounds almost like banking: “here is your token, keep it safe, don’t lose it.” But in essence it’s a very simple and convenient thing: subscribe(...) returns an object (or a function) that lets you remove the subscription later.

We’ll make the token as simple as possible: it will be a function () -> Unit, which you can call to unsubscribe.

Why a function specifically? Because Kotlin can store and return functions as values, and that fits our task naturally.

Implementing Event<T> with a token

Here is the updated Event<T>:

class Event<T> {
    private val listeners = mutableListOf<(T) -> Unit>()

    fun subscribe(listener: (T) -> Unit): () -> Unit {
        listeners += listener

        // Return an unsubscribe token: call -> listener is removed
        return { listeners.remove(listener) }
    }

    fun emit(value: T) {
        for (l in listeners) l(value)
    }
}

What happened here “magically,” but is actually logical:

The function we return ({ listeners.remove(listener) }) captures two things from the outer context: listeners and listener. This is a closure: the returned function “remembers” exactly what needs to be removed.

Using the token

Now user code becomes very human: subscribe — get a token — later call the token when needed.

fun main() {
    val event = Event<String>()

    val unsubscribe = event.subscribe { text ->
        println("Got: $text")
    }

    event.emit("A")     // Got: A
    unsubscribe()
    event.emit("B")     // (nothing is printed)
}

Notice: we didn’t search for the lambda “by smell,” didn’t compare “text equality,” and didn’t poke inside Event for private fields (and thank Kotlin they didn’t let us). We just kept the token.

Unsubscribing twice should not break the program

A very common question: “what if I accidentally call the token twice?” Ideally, the answer should be: “nothing bad.”

Our variant listeners.remove(listener) is already safe enough: if the element is not present, remove will simply return false and won’t crash. This is a handy “bonus” of the standard collection.

Let’s check the behaviour with a small example:

fun main() {
    val e = Event<Int>()

    val unsub = e.subscribe { println("x=$it") }

    unsub()
    unsub() // repeated call — ok, there's simply nothing left to remove
}

That’s a good contract for a token: you can call it even twice, and the program doesn’t turn into a pumpkin.

A small diagram: what subscribe returns

Sometimes it helps to see this as a data flow rather than “magic that remembers things.”

flowchart TD
    A["subscribe(listener)"] --> B["listeners += listener"]
    B --> C["return unsubscribeToken"]
    C --> D["unsubscribeToken()"]
    D --> E["listeners.remove(listener)"]

The point of the diagram is simple: the token is not an “ID of a subscription in a database.” It’s just a function that closes over (captures) the needed variables and can remove the listener.

5. Example: “enable audit / disable audit”

Now let’s “ground” this mechanism in our expense tracker. Imagine that audit is a subscriber that prints the line “AUDIT: ...” to the console. We want to enable/disable it by command (or by a setting), without adding the handler again every time.

Let’s do it in the most straightforward way: store the unsubscribe token in a variable.

class AuditFeature(private val service: ExpenseService) {
    private var unsubscribe: (() -> Unit)? = null

    fun enable() {
        if (unsubscribe != null) return

        unsubscribe = service.expenseAdded.subscribe { e ->
            println("AUDIT: added ${e.title} for ${e.amount}") // AUDIT: added Coffee for 250
        }
    }

    fun disable() {
        unsubscribe?.invoke()
        unsubscribe = null
    }
}

There are two nice effects here. First, enable() becomes idempotent: if audit is already enabled, it won’t subscribe a second time. Second, disable() is safe: if audit is already disabled, unsubscribe will be null, and nothing bad will happen.

6. Comparing approaches and duplicate subscriptions

Why a token is better than unsubscribe(listener)

Sometimes you want to make the API “more mature” and add a method unsubscribe(listener). Formally this is possible, but it’s almost always inconvenient: you have to store listener somewhere, and beginners regularly create a “new identical lambda” and then wonder why unsubscribing doesn’t work.

The unsubscribe token removes the problem: you don’t need to search for the listener, the token already “remembers” what to remove.

Approach What it looks like What’s inconvenient in real life
Unsubscribe “by listener”
unsubscribe(listener)
You must store the reference to the listener and pass the same object, otherwise it won’t be removed
Unsubscribe “by token”
val unsub = subscribe(...)
You must store the token, but it’s usually simpler and clearer (one variable)

If the same listener is subscribed twice

Here’s a slice of reality. We agreed (as part of a minimal contract) that subscribe does not deduplicate. That means, in theory, you can subscribe the same handler object twice, and then it will be called twice.

fun main() {
    val e = Event<String>()

    val listener: (String) -> Unit = { println("Got: $it") }

    val u1 = e.subscribe(listener)
    val u2 = e.subscribe(listener)

    e.emit("X")
    // Got: X
    // Got: X

    u1()
    e.emit("Y")
    // Got: Y   (one subscription is still left)

    u2()
    e.emit("Z")
    // (silence)
}

This behaviour is logical if you think “this is a list of subscriptions,” not “a set of unique handlers.” The main thing is to treat this as part of the contract and not be surprised. If you ever need a different policy (for example, banning duplicates), that’s a separate design decision, not a “small tweak.”

7. Callable references :: and unsubscribing

Kotlin lets you pass not only lambdas, but also function references via ::. This is also a function value, just obtained not from a lambda, but from an existing function.

For example:

fun onExpenseAdded(e: Expense) {
    println("HANDLER: ${e.title}") // HANDLER: Coffee
}

fun main() {
    val service = ExpenseService()

    val handler: (Expense) -> Unit = ::onExpenseAdded
    val unsub = service.expenseAdded.subscribe(handler)

    service.addExpense("Coffee", 250)
    unsub()
}

The same discipline matters here: if you need later unsubscription “by reference,” store it in a variable (handler). If you write subscribe(::onExpenseAdded) in different places and expect “it’s the same reference,” you can get confused — it’s much calmer to keep the reference as a value and not rely on luck.

9. Typical mistakes

Mistake #1: trying to unsubscribe with the “same” lambda.
A very natural desire: “I wrote { println(...) }, so if I write it again, it will be the same thing.” No: it’s a new function object, so remove won’t find it. If you design an event API, an unsubscribe token is the best way to avoid forcing the user to remember lambda reference identity.

Mistake #2: not saving the unsubscribe token and later trying to “disable it somehow.”
A token works only if you keep it somewhere. If you subscribed in one place, and want to unsubscribe in another, but lost the token, you slide back into “searching for the lambda.” The practice is simple: if a subscription is temporary, the token is stored next to whoever controls the lifecycle of that subscription.

Mistake #3: recreating subscriptions on every enable and getting duplicates.
The scenario “enabled audit” → “enabled audit again” should be predictable. If every enable() creates a new subscription, output will start duplicating, and you’ll think it’s a poltergeist. Usually this is fixed by storing the token in a var unsubscribe, and on repeated enabling you don’t subscribe again until you’ve disabled the previous subscription.

Mistake #4: expecting unsubscription to affect an already-started emission “right now.”
Naively, you might want a handler to disappear from the current iteration the moment it unsubscribes. In the minimal Event implementation this behaviour is not guaranteed and depends on how emit is written. For now we only fix the contract “after unsubscribing, future emit calls must not invoke the handler,” and we’ll analyse robust emission under subscription changes separately.

Mistake #5: making listeners public “to make unsubscribing easier.”
That’s a fast track to external code removing, adding, and shuffling listeners however it wants, and Event turning into an unmanageable mess. Keep the listeners collection private, and expose only clear operations (subscribe, emit, and unsubscribe tokens).

1
Task
Kotlin SELF,  57 level,  2 lecture
Locked
Twin lambdas
Twin lambdas
1
Task
Kotlin SELF,  57 level,  2 lecture
Locked
Unsubscribe token
Unsubscribe token
1
Task
Kotlin SELF,  57 level,  2 lecture
Locked
Two unsubscriptions
Two unsubscriptions
1
Task
Kotlin SELF,  57 level,  2 lecture
Locked
Debug feature
Debug feature
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet