Static APIs vs Instances
Opened this issue · 1 comments
I'm following up on the #26 not to imply we should use static APIs, but more of a way to continue discussion and compare tradeoffs.
I think couple of constraints raised against the static API were
- Static functions not allowing us to leverage interface to guarantee a queue has the expected functions
- Having to pass configs as opposed to Queue instance
I'll try to address both as followup comment
Static functions not allowing us to leverage interface to guarantee a queue has the expected functions
While it is true that we are not able to use syntax like @implementns {Queue<{id: string}>}
, that does not mean we are not able to leverage type checker to type check type / interface compliance. It's can be done as follows:
export const put = ...
export const peek = ...
/** @type {API.Queue<...>} */
export const queue = { put, peek }
Type checker will ensure API compatibility or it will point out what the problems are. It is unfortunate you need to create that additional object and there is no way to annotate the whole module, yet in practice I find that even such annotation is rarely required, because often you'll have a test that e.g. expects Queue
instance and if module was not compatible it would complain (We have similar stuff in IPLD codecs)
Having to pass configs as opposed to Queue instance
This is a really difficult one to address, because it is true you do need to pass config around and that does feel annoying. My counter argument here is that it does not really matter if you pass object called config
or whether you pass object called queue
. At the end the day you'll just end up writing object.put(value)
or put(object, value)
. What it all boils down to is following:
- In case of instances you end up using some instantiation logic e.g.
Queue.create(config)
and consequently introducing a state. - In case of statics you end up having to do imports instead
import { put, peek } from ...
and avoid introducing state.
Unlike the instance case it's really easy to mix and match imports e.g. I could import some functions from "producer" other functions from "consumer" module than export them all and I have "queue" implementation. If you have instance things are not that easy, can no longer mix and match, you have to wrap two instances into composite ones or do the producer.peek.bind(producer)
again introducing more state and order in which things need to be setup.
Comparing tradeoffs I find preferring statics, especially given that there are two Ecmascript proposals to address ergonomics of put(object, value)
in comparison to object.put(value)
- Bind operator which will allow
object::put(value)
. - Pipeline operator which will allow for
object |> put(%)
When aren't statics better ?
In practice I think there is only scenario where statics fall short is when you have a several domain specific implementations of the interface that needs to be defined across package boundaries. More concretely if I have high order generic functions that need to operate on queues e.g. pipe(consumer, producer)
. If our put
and peek
aren't universal across instances we won't be able to implement such pipe
function. It is however worth calling out that even in such instances classes may not be a best option and ofter there could be alternatives:
- Pattern matching is usually better when you have bound number of variants, which is what most functional languages tend to do:
export const pipe = (source, destination) {
switch (source.type) {
case 'Buffered':
....
case 'Rendezvous':
....
}
}
- Make operator part of the interface, e.g. if
put
implementation is universal, butpeek
isn't you can make something likemove
that takes producer as an argument.
Conclusion
I think in our case we do not need a generic pipe
so static functions would allow avoid incidental state and initialization order at expense of having to do a function call as opposed to method call.