I have this horrible habit of perseverating on ideas when they overlap with concepts I'm working to internalize/intuit more myself. I also have a strong bias that iterating over the core/simple concepts, is an efficient path to tackling the "bigger stuff". It has truth, because the bigger is never more than some combination or projection/inference of the simpler.
I've gone ahead and articulated several ideas for how to expand on your introduction. I've expanded on expressing the value of the approach and a concrete method that might be a useful routine for how to apply what you present, elsewhere.
While I think what I'm saying is useful, I may have over-simplified to a point where it needs to be "called-out"; so a clear take is my trying to be precise, but is done so in the spirit of a churning of ideas.
Target audience
Per your target audience, I imagine the reader knows what they are doing. The person codes, a lot. How much they know Rust versus say C and a strongly typed language other than Rust, is less critical. The proficient Rust user, is fluent with "just doing it" and might benefit from something more than the often dominant, implied translation C++ -> Rust
.
The insights are likely less from "I've never seen this before" but rather, "I never thought of it that way". Insights from simple examples that both broaden, and facilitate a complete thought process, that in doing so, articulate a useful "so what" that can be missed (or forgotten) when "just doing it" (even at peak effectiveness). Quick aside: there is this place, where I can know something really well, so well I know how to implement whatever I'm thinking in the moment. That code can often be difficult to maintain. The target audience is always open to a replay of how the simple, often occurring patterns, can apply more broadly than what we remembered.
Use the simplicity of the fn color_to_rgb
to present a broader view
The introduction using A prototypical example: enums over strings is a good example because it quickly gets the reader into the right mindset for what to expect. The expectation being that you are now going to take what we know to be a good thing (per the example used), to a new level of understanding and ability to apply.
Here are some additional thoughts on how to convey the value of the approach.
One way to articulate what the type system allows for in a way that frames the subsequent demonstrations might be:
The type system enables a precise alignment between what the compiler will admit as valid input to a function, with values the function is equipped to process.
Safety and expressiveness
The Rust safety features and "fearless concurrency" are clearly touted and realized benefits of this capacity. However I suspect, "expressiveness" and "fearless refactoring" have not received their due, if only because the "top-line" buffer is already full (not to mention, the bandwidth required to help navigate the borrow checker).
Given this, the introduction might talk more about the expressiveness enabled by the type system. Concretely, the ability to align what a function commits to doing, with what it actually does, depends on the finesse, the precision of the type expression.
every function inherently specifies the set of valid "something it can process" inputs by definition of the implementation (implementation => valid inputs
); not the other way around.
This fixed set of inputs is often referred to as a function's domain (related but not the same as "domain" in "domain-driven design"). The "so what" here is that in many ways this is all you need to describe and ensure "safe code": align the function's domain with what the compiler admits as input. Done.
Option
might be a flag for needing to do more with the type system
What the use of Option
and the like (Result
) allows us to gloss over, is that the reason for using it has more to do with a failure to align what the compiler admits as input with what the function is inherently equipped to process. For instance, using i32
to describe valid input for the denominator of a function that computes the value of a fraction. The "immutable truth" is that the domain for the function 1/denominator
is not the set of integers in i32
but of course, a type NonZero
that can only be instantiated accordingly.
The gap between a function's domain and the set of inputs permitted by the design, is an opportunity for the type system to "do the work for you" of aligning inputs with the function's domain.
The key idea, and likely a useful way to exploit the power of the type system, is to use it to precisely align the inherent truth of what the function is meant to process with the inputs it admits. From this perspective, as useful as Option
is, it must be seen for what it is: the equivalent of unsafe code in that it is infinitely useful, but sometimes "a shortcut". No matter, it should be isolated, and tagged as precisely as possible. Admittedly, and to the point, even when using NonZero
the application might still require the use of Option
, now just further "up-stream" when trying to instantiate NonZero
from raw input.
Where to find a missing type
In both versions of the color_to_rgb
function, the Rust type system enforces alignment of the function's domain with the set of permitted inputs. The key is in the how. In the first (_bad), the trick was to expand the domain of the function compared to the other (_good). This was accomplished with an implementation that could process
junk part of the domain -> None
The second (_good) approach instead used the type system to more narrowly specify the set of admissible inputs; a specification that permitted a more precise expression of the intent. In other words, the type system closed the gap between admitted inputs and the domain of the more precise function.
What follows may be too granular, but it works for the example. How do you decide which version of a function is "_bad" vs "_good" in a way that reveals opportunities more generally to empower the type system "just that much more"?
The process can become slippery (circular) because "how can we not" think about the inputs the function needs to process whilst designing the body of our functions? Isn't building to an interface a well established best practice; something the function type signature ultimately express?
The source of immutable truth is not related to an "interface"
To better understand the trade-offs in how to close the gap, without getting too circular anyway, I have found the following to be useful. First, where can I find "a hard truth", "a single truth" about the task. As mentioned, the body of the function dictates the function's domain, while they are inseparable, one defines the other; the implementation implies the function's domain.
How does this square with "designing to an interface". I might argue that the "_bad" is good if I had to design to an interface that accepts as input &str
or worse, some untyped "goodness only knows" input coming from the wild-wild "untyped" world out there.
Information is like matter; it doesn't come from "thin air"
More peeling of the onion can give us a means to better understand if options exist that both meet the spec of the interface, whilst benefiting from a design inspired by inherent qualities of the "core computation". Where the latter involves creating types that align with the "natural" offshoots of the computation (an immutable truth of sorts).
On the other hand, to build to an interface, think composition of functions. To figure out which functions, start with the ones that generate the most "new information" by what is hosted in the body of the function, the most computationally-driven functions; only those functions hold some inherent truth about the task. Framed as such, likely more so than the interface being built to.
_bad
is relatively so because it built to an interface (too early anyway)
The design trap the "_bad" function fell into, was to build to an interface that accepts &str
as input. The implementation of the function was "interface driven". The details of the implementation clearly show that there are two things going on: input -> (valid | invalid); valid -> (u8,u8,u8)
.
The "aha" moment for how the type system can help, is to better understand how "_good" encapsulates valid -> (u8,u8,u8)
using the type system to align admitted input with the "inherent" domain of the function. It resonates as a design, because it describes where the computation generates new information: encoding the name of a primary color into its corresponding rgb format.
_good is relatively so because it is more precise despite being more verbose
With this "source of truth" now articulated, the "build" to an interface design is likely that much better for it.
// we learned the value of PrimaryColor by understanding the inherent truth
// that comes from this computation
fn color_to_rgb (color: PrimaryColor) -> (u8, u8, u8) { .. }
// "fill-in" the gaps to meet the meet the need of the interface
// ...must accept &str as input
fn try_from(raw_input: &str) -> Option<PrimaryColor> { .. }
// compose to fullfill the commitment to the interface
fn try_color_to_rgb(raw_input: &str) -> Option<(u8,u8,u8)> {
let try = try_from(raw_input)?;
Some(color_to_rgb(try))
}
It's worth noting that it is by no means less succinct. Likely the opposite. However, where our computations add the most value likely overlap with where being more precise with the type definitions make sense. This is especially true if we consider the power of type inference that by definition of "proof by construction" (see witness later) gives us a capacity to "refactor with confidence".
Hotel California
Finally, I think this more complete version of the example tees-up how a strictly typed universe provides a richer set of tools for interacting with the wild-wild untyped world. However, in exchange for safe, runtime code, we have what is likely a more burdensome and inescapable task of needing to deal with fn (wild: &[_]) -> Result<T, E>
in our design. Like unsafe code, and the example above, the best we can do is isolate/encapsulate precisely where that logic needs to be. A big net win, and one "reading this book" will make that much more so. If "you can never leave", I'd rather face my truth with types :))