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!
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.
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.