storacha/w3filecoin-infra

Static APIs vs Instances

Opened this issue · 1 comments

Gozala commented

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

  1. Static functions not allowing us to leverage interface to guarantee a queue has the expected functions
  2. Having to pass configs as opposed to Queue instance

I'll try to address both as followup comment

Gozala commented

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)

  1. Bind operator which will allow object::put(value).
  2. 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:

  1. 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':
          ....
   }
}
  1. Make operator part of the interface, e.g. if put implementation is universal, but peek isn't you can make something like move 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.