In Kotlin, how do I add extension methods to another class, but only visible in a certain context?
Asked Answered
B

2

23

In Kotlin, I want to add extension methods to a class, for example to class Entity. But I only want to see these extensions when Entity is within a transaction, otherwise hidden. For example, if I define these classes and extensions:

interface Entity {}

fun Entity.save() {}
fun Entity.delete() {}

class Transaction {
    fun start() {}
    fun commit() {}
    fun rollback() {}
}

I now can accidentally call save() and delete() at any time, but I only want them available after the start() of a transaction and no longer after commit() or rollback()? Currently I can do this, which is wrong:

someEntity.save()       // DO NOT WANT TO ALLOW HERE
val tx = Transaction()
tx.start()
someEntity.save()       // YES, ALLOW
tx.commit()
someEntity.delete()     // DO NOT WANT TO ALLOW HERE

How do I make them appear and disappear in the correct context?

Note: this question is intentionally written and answered by the author (Self-Answered Questions), so that the idiomatic answers to commonly asked Kotlin topics are present in SO. Also to clarify some really old answers written for alphas of Kotlin that are not accurate for current-day Kotlin. Other answers are also welcome, there are many styles of how to answer this!

Bucella answered 14/1, 2016 at 18:45 Comment(2)
good question! But why 2 answers?Impediment
I wrote one, then I wrote another.Bucella
B
23

The Basics:

In Kotlin, we tend to use lambdas passed into other classes to give them "scope" or to have behaviour that happens before and after the lambda is executed, including error handling. Therefore you first need to change the code for Transaction to provide scope. Here is a modified Transaction class:

class Transaction(withinTx: Transaction.() -> Unit) {
    init {
        start()
        try {
            // now call the user code, scoped to this transaction class
            this.withinTx()
            commit()
        }
        catch (ex: Throwable) {
            rollback()
            throw ex
        }

    }
    private fun Transaction.start() { ... }

    fun Entity.save(tx: Transaction) { ... }
    fun Entity.delete(tx: Transaction) { ... }

    fun Transaction.save(entity: Entity) { entity.save(this) }
    fun Transaction.delete(entity: Entity) { entity.delete(this) }

    fun Transaction.commit() { ... }
    fun Transaction.rollback() { ... }
}

Here we have a transaction that when created, requires a lambda that does the processing within the transaction, if no exception is thrown it auto commits the transaction. (The constructor of the Transaction class is acting like a Higher-Order Function)

We have also moved the extension functions for Entity to be within Transaction so that these extension functions will not be seen nor callable without being in the context of this class. This includes the methods of commit() and rollback() which can only be called now from within the class itself because they are now extension functions scoped within the class.

Since the lambda being received is an extension function to Transaction it operates in the context of that class, and therefore sees the extensions. (see: Function Literals with Receiver)

This old code is now invalid, with the compiler giving us an error:

fun changePerson(person: Person) {
    person.name = "Fred" 
    person.save() // ERROR: unresolved reference: save()
}

And now you would write the code instead to exist within a Transaction block:

fun actsInMovie(actor: Person, film: Movie) {
    Transaction {   // optional parenthesis omitted
        if (actor.winsAwards()) {
            film.addActor(actor)
            save(film)
        } else {
            rollback()
        }
    }
}

The lambda being passed in is inferred to be an extension function on Transaction since it has no formal declaration.

To chain a bunch of these "actions" together within a transaction, just create a series of extension functions that can be used within a transaction, for example:

fun Transaction.actsInMovie(actor: Person, film: Movie) {
    film.addActor(actor)
    save(film)
}

Create more like this, and then use them in the lambda passed to the Transaction...

Transaction { 
   actsInMovie(harrison, starWars)
   actsInMovie(carrie, starWars)
   directsMovie(abrams, starWars)
   rateMovie(starWars, 5)
}

Now back to the original question, we have the transaction methods and the entity methods only appearing at the correct moments in time. And as a side effect of using lambdas or anonymous functions is that we end up exploring new ideas about how our code is composed.

Bucella answered 14/1, 2016 at 18:45 Comment(0)
B
12

See the other answer for the main topic and the basics, here be deeper waters...

Related advanced topics:

We do not solve everything you might run into here. It is easy to make some extension function appear in the context of another class. But it isn't so easy to make this work for two things at the same time. For example, if I wanted the Movie method addActor() to only appear while inside a Transaction block, it is more difficult. The addActor() method cannot have two receivers at the same time. So we either have a method that receives two parameters Transaction.addActorToMovie(actor, movie) or we need another plan.

One way to do this is to use intermediary objects by which we can extend the system. Now, the following example may or may not be sensible, but it shows how to go this extra level of exposing functions only as desired. Here is the code, where we change Transaction to implement an interface Transactable so that we can now delegate to the interface whenever we want.

When we add new functionality we can create new implementations of Transactable that expose these functions and also holds temporary state. Then a simple helper function can make it easy to access these hidden new classes. All additions can be done without modifying the core original classes.

Core classes:

interface Entity {}

interface Transactable {
    fun Entity.save(tx: Transactable)
    fun Entity.delete(tx: Transactable)

    fun Transactable.commit()
    fun Transactable.rollback()

    fun Transactable.save(entity: Entity) { entity.save(this) }
    fun Transactable.delete(entity: Entity) { entity.save(this) }
}


class Transaction(withinTx: Transactable.() -> Unit) : Transactable {
    init {
        start()
        try {
            withinTx()
            commit()
        } catch (ex: Throwable) {
            rollback()
            throw ex
        }
    }

    private fun start() { ... }

    override fun Entity.save(tx: Transactable) { ... }
    override fun Entity.delete(tx: Transactable) { ... }

    override fun Transactable.commit() { ... }
    override fun Transactable.rollback() { ... }
}


class Person : Entity { ... }
class Movie : Entity { ... }

Later, we decide to add:

class MovieTransactions(val movie: Movie, 
                        tx: Transactable, 
                        withTx: MovieTransactions.()->Unit): Transactable by tx {
    init {
        this.withTx()
    }

    fun swapActor(originalActor: Person, replacementActor: Person) {
        // `this` is the transaction
        // `movie` is the movie
        movie.removeActor(originalActor)
        movie.addActor(replacementActor)
        save(movie)
    }

    // ...and other complex functions
}

fun Transactable.forMovie(movie: Movie, withTx: MovieTransactions.()->Unit) {
    MovieTransactions(movie, this, withTx)
}

Now using the new functionality:

fun castChanges(swaps: Pair<Person, Person>, film: Movie) {
    Transaction {
        forMovie(film) {
            swaps.forEach { 
                // only available here inside forMovie() lambda
                swapActor(it.first, it.second) 
            }
        }
    }
}

Or this whole thing could just have been a top level extension function on Transactable if you didn't mind it being at the top level, not in a class, and cluttering up the namespace of the package.

For other examples of using intermediary classes, see:

  • in Klutter TypeSafe config module, an intermediary object is used to store the state of "which property" can be acted upon, so it can be passed around and also changes what other methods are available. config.value("something").asString() (code link)
  • in Klutter Netflix Graph module, an intermediary object is used to transition to another part of the DSL grammar connect(node).edge(relation).to(otherNode). (code link) The test cases in the same module show more uses including how even operators such as get() and invoke() are available only in context.
Bucella answered 14/1, 2016 at 18:45 Comment(4)
Sounds almost like a GoF State Pattern. :) Maybe you could extract a pattern out of this for Kotlin.Willin
Haven't heard GoF in a long time.Bucella
Old question, but great answer! I'm trying to solve a problem and I've figured it out with something like this solution. Is there any other examples/articles you know of? I just don't even know a name for thisWellfixed
@PrestonGarno You would want to look up DSL Builder articles and topics around Kotlin. This is the area it most likely to discuss these topics.Bucella

© 2022 - 2024 — McMap. All rights reserved.