1

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 interface s2? The interface allows for immutability, i.e. I can have fresh instances (new Stateful object) instead of returning this 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?

9
  • This sounds like an XY problem. For a simple progression of adding required elements, a progression of types / classes is straightforward, as in your example or in: 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?
    – J_H
    Commented Mar 13, 2023 at 15:56
  • Here is an ancient 1981 state machine described in rfc9293. Suppose that Kotlin (Haskell?) instead of C code was manipulating the PCB protocol control block, and sending packets. Could I use your proposed technique to ensure (at compile time) that only valid .send_fin() / .send_ack() packet messages are ever sent out? Sorry, I'm not yet seeing how to do that. I would prefer to rely on a SPIN validation.
    – J_H
    Commented Mar 13, 2023 at 16:04
  • What is the worst-case problem that can occur if someone makes the mistake of calling changeState multiple times? To what length (and inconvenience, cost) are you willing to go to prevent that from happening? Commented Mar 14, 2023 at 12:46
  • What language are we looking at here? Commented Mar 14, 2023 at 13:14
  • @BartvanIngenSchenau System would be in illegal state, so it must be prevented somehow.
    – sfiss
    Commented Mar 14, 2023 at 14:43

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.