Scenario: Given a class with some internal state and methods to manipulate this state, I want to limit the exposed methods that are available to potential clients/users of my API.
TLDR: In the following, I present a simple example describing the problem and four alternatives I have considered. Answers should either point out problems with the alternatives or even better let me know if there is a way to address my problem in a different way. However, I might also have tunnel vision. In that case, please explain why limiting the exposed methods at compile-time is not really needed (What is the runtime alternative? Exceptions or Result-Monads?). I used Kotlin examples, but any language is fine.
Consider the following example, where I require users to first call changeState
before then only calling incrementState
. You could also consider the system of first being in a state "Changeable", then transitioning into state "Incrementable" after changeState
was called.
class Stateful {
private var state: Int = 0
fun changeState(value: Int) {
state = value
}
fun incrementState() {
state++
}
}
One approach would be to only explose the interfaces that are available in the current state, and then transition to a new state with a different interface (similar as stateful builders would do it):
interface S1 {
fun changeState(value: Int): S2
}
interface S2 {
fun incrementState(): S2
}
class Stateful: S1, S2 {
private var state: Int = 0
override fun changeState(value: Int): S2 {
state = value
return this
}
override fun incrementState(): S2 {
state++
return this
}
}
Here I see the following problems:
- How do I enforce that clients do not reuse the old instance
s1
after the transition, but use the returned interfaces2
? The interface allows for immutability, i.e. I can have fresh instances (new Stateful object) instead of returningthis
and not care about the old instance. - Without SELF types, is returning
this
a problem here?
A second approach would be to encapsulate the behavior in distinct classes:
class Change(var state: Int) { // could also implement S1
fun changeState(value: Int): Increment {
state = value
return Increment(state)
}
}
class Increment(var state: Int) { // could also implement S2
fun incrementState(): Increment {
state++
return Increment(state)
}
}
The problem I see here is that in a real-world implementation, manipulating the state within a single class is easier and no copying/transfer of the state is required. An internal State-Holder-object could make passing the state easier, but would make it mutable again.
I have also considered two "patterned" approaches:
A) StateMachine
enum class S { Change, Increment }
sealed interface E
data class OnChange(val state: Int): E
object OnIncrement: E
interface SM {
var state: Int // internal
var currentState: S
fun transition(event: E)
fun isValid(event: E): Boolean
}
What I don't like about the state machine (apart from probably pattern matching + delegating a lot inside the transition rather than simply letting the client call the correct method) is that I can not restrict the available transitions (events) at compile-time.
B) State pattern
Problem: My available methods are not unifiable under a common interface.
Here is how clients would use the respective approaches:
// Approach 1
val s1: S1 = ...
var s2: S2 = s1.changeState(1000)
s2 = s2.incrementState()
// Approach 2
val c: Change = ...
var i:Increment = c.changeState(1000)
i = i.incrementState()
// Approach 3
val sm: SM = ...
// Problem here is that we need to assert which transitions are valid
sm.transition(OnChange(1000))
sm.transition(OnIncrement)
How do I best design my API so that using my class is less errorprone to use at compile-time, since the correct subset of methods is available depending on the current state of the system?
Is this something that is desirable at all?
connection = CacheDir('/tmp').connect(jdbc_string); connection.cached_query(...)
. If there's complex state machine transitions, then signalling fatal error at runtime may be a better match. What I've not yet seen in your question is: What kind of app-developer mistakes have we seen historically that we wish to squelch at compile time?changeState
multiple times? To what length (and inconvenience, cost) are you willing to go to prevent that from happening?