Builders vs option maps

I like builders and have written APIs that provide builder patterns, but I really prefer option maps where the language makes it possible. Instead of a builder like

Wizard wiz = new WizardBuilder("some string") .withPriority(1) .withMode(SOME_ENUM) .enableFoo() .disableBar() .build();

I prefer writing something like

Wizard wiz = new Wizard("some string", {:priority 1 :mode SOME_ENUM :foo? true :bar? false})

Why?

  1. Option maps are usually shorter in languages with map literals.
  2. Option maps are data structures, not code. They’re easier to store and read from files. You can put them in databases or exchange them across the network. Over and over again I see boilerplate code that sucks in JSON and calls a builder fun for each key. This is silly.
  3. Builders in most languages (perhaps not Rust!) require an explicit freeze/build operation because they’re, well, mutable. Or you let people clobber them whenever, I guess. :-/
  4. Option maps compose better. You can write functions that transform the map, or add default values, etc, and call a downstream function. Composing builders requires yielding the builder back to the caller via a continuation, block, fun, etc.
  5. Option maps are obviously order-independent; builder APIs are explicitly mutating the builder, which means the order of options can matter. This makes composition in builders less reliable.

Why not use option maps everywhere? I suspect it has to do with type systems. Most languages only have unityped maps (e.g. java.util.Map<String, Object>) where any key is allowed, but options usually have fixed names and specific but heterogenous types. The option map above has booleans, integers, and enums, for example.

In languages like Java, it’s impossible to specify type constraints like “This map has a :foo? key which must be a boolean, and has a :mode key that can only be one of these three values”. Using a builder with explicit type signatures for each function lets you statically verify that the caller is using the correct keys and providing values of the appropriate type. [1]

Of course, all this goes out the window when folks start reading config files at runtime, because you can’t statically verify the config file, so type errors will appear at runtime anyway–but you can certainly get some static benefit wherever the configuration is directly embedded in the code.

[1] Know what a typed heterogenous map is in Java? It’s an Object! From this perspective, builders are just really verbose option maps with static types.

Glen Mailer
Glen Mailer, on

It’s possibly also worth noting that you can always use a builder-style API to create a config map if that’s what you desire.

Yohan Launay
Yohan Launay, on

I’d say the main issue with using maps is refactoring. If you know that you are refactoring your code very often, it becomes very tedious to track which piece of code uses what when you have map-based constructors all over the place. It is also much harder for autocompletion to work.

I’ve used and still using both, although I do like the map approach, I’d love for Java to have named parameters :)

Thanks for sharing

tobi
tobi, on

The fluent builder pattern is especially harmful in C# where you can just write

new MyConfig() { A = 1, B = "2", ... }

The builder adds nothing but takes away. It also is harder to explain.

zerkms
zerkms, on

where you can just write

Your code is based on the assumption that there are public properties and you know how to initialize them.

Fluent builder separates build process from internal state, your code does the opposite job.

Luaan
Luaan, on

Tobi, a better example would be a method with named parameters with default values (of “not set”). With a bit of boilerplate, you get pretty close to the samples in the article:

new MyConfig(priority: 1, mode: SomeEnum, isOk: true)

It still allows you to keep immutable things immutable, it still allows you to let the config (and the config-user) classes handle their jobs rather than pushing it from the outside. And it is strongly and statically typed. Sure, it’s compile-time syntactic sugar, but this article is all about static code - as Aphyr said, you can’t statically verify a config file. XML schema only goes so far. And it’s still rather easy to compose. If you do want to store the config elsewhere, you always have to implement the whole config explicitly, no way to “forget” a setting. Now if only you could have a non-nullable type in C# :D

Tony
Tony, on

Would protocol buffers work just as well as option maps in a language like Go, where you creating a new PB looks a lot like your option map code?

Luis
Luis, on

The problem with maps is that if they’re statically typed, then the values must be homogeneously typed (which routinely leads to stringly typing), but if they’re dynamically typed then they’re not type-safe. The middle ground here is, surprise surprise, record types.

A pattern you often see in Haskell is to model an option set as a monoid, where the identity element is the default options, and the monoid’s operator combines options. This ties in with both the record types and the builder approach, because (a) a record type whose field types are all monoids is also a monoid, and (b) a chain of setter invocations is a monoid as well.

Named parameters with default values are equivalent to record types + monoids too.

Julian
Julian, on

I hate that our syntax clouds our intent in programming. Quick, someone, write some intent-translating software, then the syntax won’t matter so much. Oh, yeah, right, Charles Simonyi already did, but it’s closed source. Sadtimes.

Christian
Christian, on

How easy would this be in languages with named parameters with default values?

Wizard wiz = Wizard("some string", priority=1, mode=SomeEnum, enableFoo=true, enableBar=false)

Aphyr
Aphyr, on

The problem with maps is that if they’re statically typed, then the values must be homogeneously typed

This is not a problem with maps; this is a problem with type systems that assume maps are homogenous. Some type systems can represent heterogenous maps. Take, for instance, this core.typed type from Knossos, which represents a map for tracking statistics. The type Stats is a map containing a key :extant-worlds which must be an AtomicLong, a key :skipped-worlds which must be a Metric, and so on.

(defalias Stats "Statistics for tracking analyzer performance" (HMap :mandatory {:extant-worlds AtomicLong :skipped-worlds Metric :visited-worlds Metric}))
Aphyr
Aphyr, on

How easy would this be in languages with named parameters with default values?

Named parameters usually mean wrapper functions have to explicitly pass down every argument to the function they wrap, instead of just passing it a single map and not having to care about the contents. I’ve found that pattern makes refactoring a long, involved process and unduly couples functions which I don’t think should have to care about each other’s arguments… but some people prefer it.

Post a Comment

Please avoid writing anything here unless you are a computer: This is also a trap:

Supports github-flavored markdown for [links](http://foo.com/), *emphasis*, _underline_, `code`, and > blockquotes. Use ```clj on its own line to start a Clojure code block, and ``` to end the block.

Copyright © 2017 Kyle Kingsbury.
Non-commercial re-use with attribution encouraged; all other rights reserved.
Comments are the property of respective posters.