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?

candied_orange
  • 102,279
  • 24
  • 197
  • 315
sfiss
  • 667
  • 4
  • 7
  • This sounds like an [XY](https://meta.stackexchange.com/questions/66377/what-is-the-xy-problem/66378#66378) 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 Mar 13 '23 at 15:56
  • [Here](http://tcpipguide.com/free/diagrams/tcpfsm.png) is an ancient 1981 state machine described in [rfc9293](https://www.ietf.org/rfc/rfc9293.html#section-3.3.2). 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](https://spinroot.com/spin/whatispin.html) validation. – J_H Mar 13 '23 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? – Bart van Ingen Schenau Mar 14 '23 at 12:46
  • What language are we looking at here? – candied_orange Mar 14 '23 at 13:14
  • @BartvanIngenSchenau System would be in illegal state, so it must be prevented somehow. – sfiss Mar 14 '23 at 14:43
  • @candied_orange example given in kotlin, but any language would be fine. – sfiss Mar 14 '23 at 14:43
  • After `s1.changeState(1000); s1.changeState(2000);` the "_system would be in illegal state_". I don't understand that remark. In the sense that surely the second call would have [blown up](https://en.wikipedia.org/wiki/Design_by_contract) with fatal error, right? So we never get into an illegal state. The ctor initializes with some sentinel value that can't come in through the front door. Equivalently, use a private counter. And then changeState verifies there's a valid state machine arc before attempting a change. From the system's point of view, that second change attempt never happened. – J_H Mar 14 '23 at 15:23
  • Yes, we would catch that error and have at least a runtime exception. It's more about how to help avoid having those illegal transitions available. – sfiss Mar 14 '23 at 15:45
  • `It's more about how to help avoid having those illegal transitions available` make them available and idempotent. Problem solved. – Laiv Mar 14 '23 at 20:54

0 Answers0