WORK-IN-PROGRESS: - this material is still under development
A combination of function calls as a sequence of statements.
computer();
processor();
cores(2);
processorType(i386);
disk();
diskSize(150);
disk();
diskSize(75);
diskSpeed(7200);
diskInterface(SATA);
Function Sequence produces a series of calls with no relationship to each other except a sequence in time - most importantly there is no data relationship. As a result any relationship between the calls needs to be done through the parsing data, as a result using Function Sequence heavily means you use a lot of Context Variables.
To use Function Sequence in a readable way you usually want bare function calls. The most obvious way to do this is to use global function calls if your language allows it. Global function calls result in two main disadvantages, global functions and static parsing data.
The problem with global functions is that they are global, and thus visible everywhere. If your language has some kind of namespacing construct, you can (and should) use that to reduce the scope the function calls to the Expression Builder. A particular mechanism to handle this in Java is static import. If your language doesn't support any global function mechanism at all (such as C# and pre 1.5 Java) then you'll need to use explicit class methods to handle the call. This often adds noise to the DSL.
Global function calls are the most obvious disadvantage to global functions, but often the most annoying problem is that they force you to use static data. Static data is often a problem because you can never be entirely sure who is using it - particularly with multi-threading. This problem is particularly pernicious with Function Sequence because you need a lot Context Variables to make it work.
A good solution for both globally visible functions and static parsing data is Object Scoping. This allows you to host the functions in a class in the natural object-oriented way and gives you an object to put the parsing data. As a result I suggest using Object Scoping if you are using Function Sequence in all but the very simplest cases.
[TBD: Look into using with-like keywords.]On the whole, Function Sequence is the least useful of the function call combinations to use for DSLs. Using Context Variables to keep track of where you are in a parse is always awkward - leading to code that's hard to understand and easy to get wrong.
Despite this, there are times when you need to use Function Sequence. Often a DSL involves multiple high level statements, in this case a list of statements often makes sense as a Function Sequence as there's only a single result list and Context Variable that you need to keep track of things. So Function Sequence is a reasonable option at the top level of a language, or at the top level inside a Nested Closure. However below that top level of statements you want to form expressions using Nested Function or Method Chaining.
Perhaps the biggest reason to use Function Sequence is that you always have to start your DSL with something, and that something has to be a Function Sequence, even if there's only one call in the sequence. This is because all the other function call techniques require some kind of context. Of course, one can argue about whether a sequence with a single element is really a sequence, but that seems the best way to fit it into the conceptual framework I'm using.
A simple Function Sequence is a list of elements, so the obvious alternative is to use a Literal List. [TBD: discuss trade offs for this]
Here is the recurring computer configuration example as a DSL with Function Sequence
computer();
processor();
cores(2);
processorType(i386);
disk();
diskSize(150);
disk();
diskSize(75);
diskSpeed(7200);
diskInterface(SATA);
Although I've indented the code to suggest the structure of the configuration, that's just arbitrary use of whitespace. The script is really just a sequence of function calls with no deeper relationship between them. The deeper relationship is entirely built up using Context Variables.
As I'm using Java I can use static methods and import them into my DSL script with the static import command. This allows me to use what looks like global function calls without the problems of truly global functions. While this is handy, it doesn't avoid the problem of the parsing data, this still has to be global.
Usually I'll solve both of these problems by using Object Scoping, and I certainly would usually do that in this case. However for this case I'll look at how you might handle this example without using Object Scoping.
Even without Object Scoping I still can't bring myself to store the parsing data in static fields, instead I'll use a Singleton
class Builder... private static Builder instance; private Processor currentProcessor; private Disk currentDisk; private List<Disk> loadedDisks = new ArrayList<Disk>();
The singleton instance is entirely private in this case and is only accessed by the static methods that implement the DSL. As a result I could equally well make the parsing data use static variables. I prefer to use a Singleton here in order to keep the parsing data clearly together.
The parsing data has two Context Variables (currentProcessor and
currentDisk) together with a list to hold the disks. The Context Variables also act as results for those elements.
The first clause in the script is computer. As this
acts as the top level of the script, I use this to initialize the
parsing data by creating a new singleton object.
class Builder...
static void computer() {
instance = new Builder();
}
To define the processor, I built up information in the processor Context Variable. As the processor domain object is immutable, I use replacement objects for my results [TBD: Unnceccessarily confusing, replace with mutable resutls].
class Builder...
static void processor() {
instance.currentProcessor = new Processor(DEFAULT_CORES, null);
}
static void cores(int arg) {
instance.currentProcessor = new Processor(arg, instance.currentProcessor.getType());
}
static void processorType(Processor.Type arg) {
instance.currentProcessor = new Processor(instance.currentProcessor.getCores(), arg);
}
private static int DEFAULT_CORES = 1;
For the list of disks, things are a little messy due to the fact that the disks are immutable. I'm using replacement again, which makes sense for a single object in a Context Variable, but is more awkward when you're using a list. My approach here is to use the Context Variable for the current disk we're working on defining and keep previously defined disks in a list.
There's no marker for the end of the configuration, so when I form the final configuration it will be both the loaded disks and the disk in the Context Variable.
class Builder...
private Disk[] disks() {
List<Disk> result = new ArrayList<Disk>();
result.addAll(loadedDisks);
if (currentDisk != null) result.add(currentDisk);
return result.toArray(new Disk[result.size()]);
}
The disk clause adds the current disk to the list if there is one, and initializes the Context Variable
class Builder...
static void disk() {
if (instance.currentDisk != null) instance.loadedDisks.add(instance.currentDisk);
instance.currentDisk = new Disk(Disk.UNKNOWN_SIZE, Disk.UNKNOWN_SIZE, null);
}
The functions that define the disk just cause replacements on the Context Variable
class Builder...
static void diskSize(int arg) {
instance.currentDisk = new Disk(arg, instance.currentDisk.getSpeed(), instance.currentDisk.getIface());
}
static void diskSpeed(int arg) {
instance.currentDisk = new Disk(instance.currentDisk.getSize(), arg, instance.currentDisk.getIface());
}
static void diskInterface(Disk.Interface arg) {
instance.currentDisk = new Disk(instance.currentDisk.getSize(), instance.currentDisk.getSpeed(), arg);
}
I use diskSpeed rather than just speed to
illustrate that Function Sequence often suffers from function name clashes since
there's no context that you can use to interpret the call. Imagine I
had processor speed as well as disk speed - I can only have one speed
function and it has to do one or the other. With this situation in
Nested Function I can use the context of the
nesting to figure out which I'm calling. With Method Chaining I can change the object I'm chaining with to
provide the context.
When I'm done building a computer definition I can then ask the builder for the result.
after running script... Computer computer = Builder.getComputer();
class Builder...
public static Computer getComputer() {
return instance.currentComputer();
}
private Computer currentComputer() {
return new Computer(currentProcessor, disks());
}
Since the builder is effectively a global object, I can get hold of the computer any time I wish after I've built it. I do have to be careful if I need to define more than one of them.