Reusing Module APIs
This is a long section, but it's foundational to a deep understanding of the full expressive power you have available to you at Build time. You're encouraged to read through this in full! But remember, while you may sometimes end up consuming Modules that were defined using these advanced features, you'll never be forced to directly use any Build time metaprogramming feature yourself. These will always be conveniences for more advanced users.
Multiple Implementations of a Module API
The most basic, and also most important form of reuse in Claro codebases will be in the form of multiple Modules sharing
a common API. This doesn't require any special syntax or setup whatsoever, once you've defined a valid
.claro_module_api
file any number of Modules may implement that API. Each claro_module(...)
definition simply needs
to declare its module_api_file = ...
to reference the same exact .claro_module_api
file.
For example, the following API:
Fig 1:
# animal.claro_module_api
opaque newtype InternalState
newtype State : struct {
name: string,
internal: InternalState
}
implement AnimalSounds::AnimalSounds<State>;
Can be implemented multiple times, by more than one Module:
Fig 2:
# BUILD
load("@claro-lang//:rules.bzl", "claro_module", "claro_binary")
claro_module(
name = "dog",
module_api_file = "animal.claro_module_api",
srcs = ["dog.claro"],
deps = {"AnimalSounds": ":animal_sounds"},
# `AnimalSounds` is referenced in this Module's API so must be exported.
exports = ["AnimalSounds"],
)
claro_module(
name = "cat",
module_api_file = "animal.claro_module_api",
srcs = ["cat.claro"],
deps = {"AnimalSounds": ":animal_sounds"},
# `AnimalSounds` is referenced in this Module's API so must be exported.
exports = ["AnimalSounds"],
)
# ...
In general, the Build targets declared above will be totally sufficient!
Going Deeper
The API definition above declares that any Module implementing the API will export a type that includes a name field,
but may configure its own internal state as it wishes. To make this example more compelling, if you read the API
closely, however, you may notice that as presently defined there would be no way for any dependent Module to actually
interact with this API as defined, because there's no way to instantiate the opaque newtype InternalState
1.
So, to actually make this API useful, implementing Modules would need to somehow explicitly export some Procedure that
gives dependents the ability to instantiate the InternalState
. You'll notice that care has been taken to make sure
that Claro's API syntax is flexible enough to allow for multiple APIs to be conceptually (or in this case, literally)
concatenated to create one larger API for a Module to implement. So that's exactly what we'll do here, with each module
exporting an additional procedure from its API to act as a "constructor" for its opaque
type.
Fig 3:
# dog_cons.claro_module_api
function create(name: string, isHappy: boolean) -> State;
Fig 4:
# cat_cons.claro_module_api
function create(name: string, favoriteInsult: string) -> State;
Fig 5:
# BUILD
load("@claro-lang//:rules.bzl", "claro_module", "claro_binary")
["BUILD", "animal.claro_module_api", "cat_cons.claro_module_api", "dog_cons.claro_module_api"],
)
genrule(
name = "dog_api",
srcs = ["animal.claro_module_api", "dog_cons.claro_module_api"],
outs = ["dog.claro_module_api"],
cmd = "cat $(SRCS) > $(OUTS)"
)
claro_module(
name = "dog",
module_api_file = ":dog_api", # Updated to use the API with a constructor.
srcs = ["dog.claro"],
deps = {"AnimalSounds": ":animal_sounds"},
# `AnimalSounds` is referenced in this Module's API so must be exported.
exports = ["AnimalSounds"],
)
genrule(
name = "cat_api",
srcs = ["animal.claro_module_api", "cat_cons.claro_module_api"],
outs = ["cat.claro_module_api"],
cmd = "cat $(SRCS) > $(OUTS)"
)
claro_module(
name = "cat",
module_api_file = ":cat_api", # Updated to use the API with a constructor.
srcs = ["cat.claro"],
deps = {"AnimalSounds": ":animal_sounds"},
# `AnimalSounds` is referenced in this Module's API so must be exported.
exports = ["AnimalSounds"],
)
# ...
In the future claro_module(...)
will accept a list of .claro_module_api
files instead of a single file to make this
pattern easier to access without having to manually drop down to a genrule(...)
to concatenate API files.
And now, importantly, multiple Modules implementing the same API can coexist in the same Claro program with no conflict!
Fig 6:
# 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",
},
)
Fig 7:
# animals_example.claro
var animals: [oneof<Cat::State, Dog::State>] = [
Dog::create("Milo", true),
Dog::create("Fido", false),
Cat::create("Garfield", "This is worse than Monday morning.")
];
for (animal in animals) {
print(AnimalSounds::AnimalSounds::makeNoise(animal));
}
Output:
Woof!
Grrrr...
This is worse than Monday morning.
Read more about Dynamic Dispatch if you're confused how the above Contract Procedure call works.
Expressing the Above Build Targets More Concisely
Now, you'd be right to think that the above Build target declarations are extremely verbose. And potentially worse, they also contain much undesirable duplication that would have to kept in sync manually over time. Thankfully, Bazel provides many ways to address both of these issues.
Remember that Bazel's BUILD
files are written using Starlark, a subset of Python, so we have a significant amount of
flexibility available to us when declaring Build targets! We'll walk through a few different options for defining these
targets much more concisely.
Using List Comprehension to Define Multiple Similar Targets at Once
The very first thing we'll notice is that the vast majority of these targets are duplicated. So, as programmers, our
first thought should be to ask how we can factor out the common logic, to avoid repeating ourselves. The below rewritten
BUILD
file does a much better job of making the similarities between the Cat
and Dog
modules explicit, and also
prevents them from drifting apart accidentally over time.
Fig 8:
# BUILD
load("@claro-lang//:rules.bzl", "claro_module", "claro_binary")
[ # This list-comprehension should feel very reminiscent of Claro's own comprehensions.
[ # Generate multiple targets at once by declaring them in a list or some other collection.
genrule(
name = "{0}_api".format(name),
srcs = ["animal.claro_module_api", "{0}_cons.claro_module_api".format(name)],
outs = ["{0}.claro_module_api".format(name)],
cmd = "cat $(SRCS) > $(OUTS)"
),
claro_module(
name = name,
module_api_file = ":{0}_api".format(name),
srcs = srcs,
deps = {"AnimalSounds": ":animal_sounds"},
# `AnimalSounds` is referenced in this Module's API so must be exported.
exports = ["AnimalSounds"],
)
]
for name, srcs in {"dog": ["dog.claro"], "cat": ["cat.claro"]}.items()
]
Declaring a Macro in a .bzl
File to Make This Factored Out Build Logic Portable
Now let's say that you wanted to declare another "Animal" in a totally separate package in your project. You could
easily copy-paste the Build targets found in the previous BUILD
file... but of course, this would invalidate our goal
of avoiding duplication. So instead, as programmers our spider-senses should be tingling that we should factor this
common logic not just into the loop (list comprehension), but into a full-blown function that can be reused and called
from anywhere in our project. Bazel thankfully gives us access to defining so-called
"Macros" that fill exactly this purpose2.
The Build targets in the prior examples could be factored out into a Macro definition in a .bzl
(Bazel extension file)
like so:
Fig 9:
# animals.bzl
load("@claro-lang//:rules.bzl", "claro_module")
def Animal(name, srcs):
native.genrule( # In .bzl files you'll need to prefix builtin rules with `native.`
name = "{0}_api".format(name),
srcs = ["animal.claro_module_api", "{0}_cons.claro_module_api".format(name)],
outs = ["{0}.claro_module_api".format(name)],
cmd = "cat $(SRCS) > $(OUTS)"
)
claro_module(
name = name,
module_api_file = ":{0}_api".format(name),
srcs = srcs,
deps = {"AnimalSounds": ":animal_sounds"},
# This Module is referenced in this Module's API so must be exported.
exports = ["AnimalSounds"],
)
And then, the macro can be used from BUILD
files like so3:
Fig 10:
# BUILD
load(":animals.bzl", "Animal")
Animal(name = "dog", srcs = ["dog.claro"])
Animal(name = "cat", srcs = ["cat.claro"])
It couldn't possibly get much more concise than this! If you find yourself in a situation where you'll be defining lots of very similar Modules, it's highly recommended that you at least consider whether an approach similar to this one will work for you.
Swapping Dependencies at Build Time Based on Build Flags
TODO(steving) I think that I probably want to move this to be its own top-level section.
TODO(steving) Fill out this section describing how this is effectively Dependency Injection handled at Build time rather than depending on heavyweight DI frameworks.
For more context, read about Opaque Types.
It's highly recommended to start with Macros, but if you find that a Macro is getting a lot of use (for example if you're publishing it for external consumption) you may find it beneficial to convert your Macro into a Bazel Rule. Bazel Rules have much nicer usage ergonomics as they enable Bazel to enforce certain higher level constraints such as requiring that certain parameters only accept files with a certain suffix. However, Bazel Rules are much more complicated to define than Macros so this should really be left to very advanced Bazel users.
In practice, if you want a Bazel Macro to be reusable outside the Build package in which its .bzl
file is
defined, you'll need to use fully qualified target labels. E.g. //full/path/to:target
rather than :target
, as the
latter is a "relative" label whose meaning is dependent on the Build package the Macro is used in, which is
usually not what you want.