Abstract Modules
In the past couple sections we've worked through examples of some fairly complex Build time metaprogramming to generate Modules that share some common behaviors between them. Having those low-level Build tools in your back pocket is something that may very well come in handy during your Claro development journey. However, in general, it's worth acknowledging that there's some inherent complexity in the prior approaches. It's my hope that the community will standardize around some well-defined set of Build design patterns that are encoded into well-known, standardized interfaces (Bazel Macros/Rules) to abstract away the low-level complexity underneath.
In this section, I'll try to demonstrate what such a standardized encoding might look like for the design pattern demonstrated in the prior sections.
Limitations of the Approach in the Prior Sections
The Animal(...)
Macro defined in the previous sections was extremely rigid. It encoded exactly one specific code
structure. It was arguably a very useful structure, but if we wanted to create an Animal(...)
that deviated even
slightly from the expected structure, you'd either have to go and refactor the Macro definition itself and all usages to
add support for new behaviors, or you'd just have to fall back to manually defining a Module, losing all Build level
code sharing that you were trying to achieve with the standardized Animal(...)
Macro.
All that said, the biggest limitation of the approach in the prior sections is that it was bespoke. While all the customizability that Build time metaprogramming gives you blows the design space wide open, it also makes it that much harder for anyone unfamiliar with the domain to follow what's going on.
Abstracting Away the "Abstract Module" Pattern Itself
Arguably, the Animal(...)
macro from the previous sections could be described as an encoding of an "Abstract Module"
(in a sense vaguely similar to Java's "Abstract Classes" - minus the object-orientation). "Abstract" in the sense that
some portions of all "Animal" Modules are known before even knowing the "concrete Animal" Modules that you'll
specifically build later on. But there's nothing about this concept itself that's unique to "Animals". All sorts of
categories of similar Modules can be imagined, and they could potentially all benefit from a similar "Abstract" base
encoding that later gets specialized for each concrete Module.
Largely as a draft demonstration of what a standardized encoding of this "Abstract Module" design pattern could look
like, Claro provides a claro_abstract_module(...)
Bazel Macro. Now, the
Animal(...)
Macro
can be auto-generated in a few lines by simply calling the claro_abstract_module(...)
Macro.
Fig 1:
# animal.bzl
load(
"@claro-lang//stdlib/utils/abstract_modules:abstract_module.bzl",
"claro_abstract_module",
)
Animal = \
claro_abstract_module(
name = "Animal",
module_api_file = "animal.claro_module_api",
overridable_srcs = {
"AnimalSoundsImpl": ":default_animal_sounds_impl.claro",
"InternalStateAndConstructor": ":default_internal_state.claro",
"MakeNoiseImpl": ":default_make_noise_impl.claro",
},
default_deps = {
"AnimalSounds": ":animal_sounds",
},
default_exports = ["AnimalSounds"],
)
Override Flexibility
On top of being a standardized encoding of this design pattern, "Abstract Modules" provide an additional mechanism for
various components of the Module to be override-able. In the Animal = claro_abstract_module(...)
declaration above,
the overridable_srcs = {...}
arg lists a few different named components that have default implementations provided
as .claro
source files that can be optionally overridden by any concrete Animal(...)
usages. For the sake of
demonstration, the "Abstract Animal Module" has been decomposed into a relatively fine granularity, allowing significant
customization to downstream users of the Macro.
So now the Animal(...)
macro can be used very similarly as in the previous sections, but with some slightly different
arguments:
Fig 2:
# BUILD
load(":animal.bzl", "Animal")
[
Animal(
name = animal,
api_extensions = [":{0}_cons.claro_module_api".format(animal)],
override = {
"InternalStateAndConstructor": ":{0}_state_and_cons.claro".format(animal),
"MakeNoiseImpl": ":{0}_noise.claro".format(animal),
},
)
for animal in ["dog", "cat"]
]
# ...
The first notable detail is that the idea of extending Module APIs is now encoded directly into the "Abstract Module"
Macros returned by claro_abstract_module(...)
in the form of the api_extensions = [...]
parameter. So now, we didn't
need to manually concatenate api files using a Bazel genrule(...)
as we
did in the prior sections. Then, notice that
the concrete cat
and dog
Animal Modules now implicitly inherit the default AnimalSoundsImpl
implementation, while
explicitly overriding InternalStateAndConstructor
and MakeNoiseImpl
with custom implementations. Now, these Module
definitions can be used exactly the same as they were when defined using the approach(es) from the prior sections.
As one final motivating example, to demonstrate something that this new Animal(...)
implementation can do that the
prior implementation(s) couldn't, we can also define a new Animal Module that overrides the default AnimalSounds
Contract implementation, by overriding AnimalSoundsImpl
:
Fig 3:
# platypus_animal_sounds_impl.claro
implement AnimalSounds::AnimalSounds<State> {
function makeNoise(state: State) -> string {
var name = unwrap(state).name;
if (unwrap(unwrap(state).internal).isWearingFedora) { # https://youtu.be/KFssdwb7dF8?si=Omgf1-D3qIBY6jO9
var codename = name[0];
return "!!!!!!!!!! REDACTED TOP SECRET MESSAGE !!!!!!!!!! - Agent {codename}";
}
return "Chirp Chirp - says {name}";
}
}
Fig 4:
# BUILD
load(":animal.bzl", "Animal")
# ...
Animal(
name = "platypus",
api_extensions = [":platypus_cons.claro_module_api"],
override = {
"AnimalSoundsImpl": ":platypus_animal_sounds_impl.claro",
"InternalStateAndConstructor": ":platypus_state_and_cons.claro",
},
)
And now, our demo program can start use the platypus
Module just as it was using the dog
and cat
Modules
previously:
Fig 5:
# BUILD
load("@claro-lang//:rules.bzl", "claro_module", "claro_binary")
# ...
claro_binary(
name = "animals_example",
main_file = "animals_example.claro",
deps = {
"AnimalSounds": ":animal_sounds",
"Cat": ":cat",
"Dog": ":dog",
"Platypus": ":platypus",
},
)
Fig 6:
# animals_example.claro
var animals: [oneof<Cat::State, Dog::State, Platypus::State>] = [
Dog::create("Milo", true),
Dog::create("Fido", false),
Cat::create("Garfield", "This is worse than Monday morning."),
Platypus::create("Perry", false),
Platypus::create("Perry", true)
];
for (animal in animals) {
print(AnimalSounds::AnimalSounds::makeNoise(animal));
}
Output:
[0.002s][warning][perf,memops] Cannot use file /tmp/hsperfdata_runner/6 because it is locked by another process (errno = 11)
Woof! - says Milo
Grrrr... - says Fido
This is worse than Monday morning. - says Garfield
Chirp Chirp - says Perry
!!!!!!!!!! REDACTED TOP SECRET MESSAGE !!!!!!!!!! - Agent P
Additional Knobs & Implementation Details
The point of this section is really to demonstrate some possibilities available to all Claro users interested in writing Bazel Macros to encode relatively complex design patterns. And, I think we can agree that being able to hand-roll the very concept of inheritance without having to make a single change to the Claro compiler itself is a rather powerful capability!
But to say it once more, this is all meant as a demonstration, rather than encouragement of specific usage of this
claro_abstract_module(...)
Macro. So, we won't dive any further into the implementation details of how this prototype
works, and we won't even go into the full range of capabilities that this prototype currently supports. However, if
you're sufficiently interested that you really wanted to know more, feel free to check out
the implementation
yourself! You'll probably learn a lot about Bazel in the process of reading through it, so it could be enlightening.