public interface Car {
void Drive();
}
public class CheapCar : Car {
public CheapCar(int milesPerGallon, string name) {...}
public void Drive() {
Console.WriteLine("Crawling along...");
}
}
public class FastCar : Car {
public FastCar(double acceleration, string name) {...}
public void Drive() {
Console.WriteLine("Speeding along...");
}
}
Now suppose I want to build a DSL for constructing them, I want the usage to look like this:
carFactory.FastCar(0.67).WithName("Speedy");
carFactory.CheapCar(123).WithName("Efficient");
My DSL conveyor-belt will need a few stages, each receiving a parameter, each bundling it up with the parameters acquired thus far and handing over to the next stage or actually building a car:
public class CarFactory {
public NameCapturer FastCar(double acceleration) {
return new FastCarNameCapturer(acceleration);
}
public NameCapturer CheapCar(int milesPerGallon) {
return new CheapCarNameCapturer(milesPerGallon);
}
}
public interface NameCapturer {
Car WithName(string name);
}
class FastCarNameCapturer : NameCapturer {
public FastCarNameCapturer(double acceleration) {...}
public Car WithName(string name) {
return new FastCar(acceleration, name);
}
}
class CheapCarNameCapturer : NameCapturer {
public CheapCarNameCapturer(int milesPerGallon) {...}
public Car WithName(string name) {
return new CheapCar(milesPerGallon, name);
}
}
Because there are two routes to specifying a name and thus two types of possible accumulation parameter there needs to be two NameCapturer implementations.
Let's see what happens when we accumulate parameters inside a function closure instead of directly inside a capturer object:
public class NameCapturer {
public NameCapturer(Func<string, Car> pipeline) {...}
public Car WithName(string name) {
return pipeline(name);
}
}
public class CarFactory {
public NameCapturer FastCar(double acceleration) {
return new NameCapturer(name => new FastCar(acceleration, name) as Car);
}
public NameCapturer CheapCar(int mpg) {
return new NameCapturer(name => new CheapCar(mpg, name) as Car);
}
}
The capturer object now provides just the language of the DSL whilst the function closure holds & fully encapsulates both the conversation that's been captured thus far and the final result.
It's very easy to generalise NameCapturer. Whilst we are at it let's add an age to each type of car and capture this with a generic AgeCapturer, a small helper class to smooth away noise from generics will be handy too.
public class NameCapturer<T> {
public NameCapturer(Func<string, T> pipeline) {...}
public T WithName(string name) {
return pipeline(name);
}
}
public class AgeCapturer<T> {
public AgeCapturer(Func<int, T> pipeline) {...}
public T WithAge(int age) {
return pipeline(age);
}
}
public class DSLHelper {
protected AgeCapturer<T> AgeCapturer(Func<int, T> pipeline) {
return new AgeCapturer<T>(pipeline);
}
protected NameCapturer<T> NameCapturer(Func<string, T> pipeline) {
return new NameCapturer<T>(pipeline);
}
}
All this stuff could conceivably exist in a 'DSL combinator' library. It's now possible to build a simple DSL for creating cars with just the following code:
public class CarFactory : DSLHelper {
public NameCapturer<AgeCapturer<Car>> FastCar(double acceleration) {
return NameCapturer(name => AgeCapturer(age => new FastCar(acceleration, name, age) as Car));
}
public NameCapturer<AgeCapturer<Car>> CheapCar(int mpg) {
return NameCapturer(name => AgeCapturer(age => new CheapCar(mpg, name, age) as Car));
}
}