H. S. Teoh
| On Mon, Oct 04, 2021 at 11:10:27PM +0000, Tejas via Digitalmars-d wrote: [...]
> Can you please list any resources on DbI?
>
> I've been finding it pretty hard to grasp, and I only managed to find a few videos by Andrei on the subject.
[...]
The basic idea is very simple (but the results are powerful indeed).
Basically, in traditional code, when a function takes a parameter, say, it specifies exactly what the type T of the parameter must be (int, float, some struct, etc.). In the function body, it makes use of the implementation details of this type in order to perform operations on the data.
auto myFunc(T data) { ... }
Experience shows, however, that often the function doesn't actually depend on *all* the details of the type T. Perhaps it uses only a small subset of its fields/methods. For example, the function may only need to use .front and .empty. So there's no reason for the function to demand that incoming arguments must be of a fixed type T; T can really be *any* type that happens to have a .front and .empty with the expected semantics. So we move from a normal (non-template) function to a template function:
auto myFunc(T)(T data) {
... // make use of .front, .empty here
}
So far so good, we all know what template functions are.
But DbI takes this one step further: suppose myFunc doesn't actually need to use .empty. Maybe there's a different way to do what it wants without using .empty, but it uses .empty because of efficiency (or whatever other reason). Still, since it's not strictly necessary, why make the function depend on it? It would be nice to be able to pass types to myFunc that don't have .empty, so that it can perform its magic (albeit somewhat less efficiently/whatever). So what we do instead is to use `static if` to inspect the incoming type T, and adapt ourselves accordingly:
auto myFunc(T)(T data) {
static if (... /* T has .empty) {
... // make use of .front, .empty here
} else {
... // alternative implementation that doesn't use .empty
}
}
Voila! Now myFunc can take both types that have .front and .empty, and types that only have .front.
(Note also that this doesn't have to be limited to the presence/absence of a field. It can be any attribute of T inspectable at compile-time. You can have static if blocks to switch between implementations based on the size of a type, for example to make decisions about how to best allocate storage for a large number of its instances, say. Or, the favorite among D users, make decisions based on some UDA that you attach to the type in order to change how the function processes it.)
This may seem trivial, but it has powerful consequences. By making myFunc independent of the existence of .empty, we've increased its scope of applicability to a wider set of types. Each static if we introduce more-or-less doubles the set of types for which the function is applicable. Instead of writing one function for types that have .front and .empty and another function for types that have only .front, we can process both according to the same logic. Instead of demanding the caller of the function to supply us with a type fulfilling requirements A, B, C, we adapt ourselves to whatever type the caller may throw at us, making use of compile-time introspection to discover what the type can do and adapting ourselves accordingly.
//
An actual example I wrote myself: a serialization module I wrote for writing out objects into text form and reading it back later to reconstruct said objects. Instead of the traditional method of inserting a .serialize method into every struct and class (wayyyy too tedious, repetitious, and highly prone to bugs caused by human error), my code instead does this:
void save(S,T)(S storage, T data) {
static if (is(T == struct)) {
... // serialize structs here
} else static if (is(T == class)) {
... // serialize structs here
} else if (is(T == U[], U)) {
... // serialize arrays here
} else if (is(T : int)) {
... // serialize int-like types here
}
... // etc
else static assert(0, "Can't serialize type " ~ T.stringof);
}
So, when I want to serialize a struct S, what do I do?
S data;
storage.save(data);
When I want to serialize an int?
int data;
storage.save(data);
When I want to serialize an array?
S[] data;
storage.save(data);
Super-easy. Notice that I deliberately named my data `data`: that means if I ever decide to change the type of my data, I don't even need to edit the .save line; I just change the type of `data`, and .save Just Works(tm). It automatically adapts itself to whatever type is passed to it. No need to call a different function, pass a ton of flags, none of that fuss and muss. Just hand it a different type, and it Just Works(tm).
But wait, there's more! What if I want to treat strings differently from other arrays? Easy, just add a static if block to .save that handles strings before it handles general arrays:
void save(S,T)(S storage, T data) {
static if ...
... else if (is(T == string)) {
... // special handling for strings here
} else if (is(T == U[], U)) {
... // handle general arrays here
} else ...
}
But what if I want a different serialization scheme for certain data types? E.g., if I have a struct MyStruct, and I don't want to serialize *all* fields, but want to exclude, say, fields that only store cached data that can be recomputed next time? Here's where UDAs come into play: I mark fields I don't want to save with a special UDA:
struct DontSerialize {}
struct MyStruct {
int i; // save this
float f; // save this too
@DontSerialize string s; // don't save this
}
void save(S,T)(S storage, T data) {
...
static if (is(T == struct)) {
foreach (field; allMembers!T) {
// skip fields tagged with DontSerialize
static if (!hasUDA!(mixin("data."~field), DontSerialize)) {
... // serialize here
}
}
}
...
}
Notice how clean this is. In a traditional approach, I'd have to pass some kind of blacklist to .save, perhaps an AA of stuff I don't want to save, or some boolean flags, etc.. But with DbI, the API of .save remains exactly the same as before:
MyStruct[] data;
storage.save(data); // <--- exactly the same as before
All I need to do is to tag the field in the definition of MyStruct -- no need to change code in all the places that pass MyStruct to .save, no need to change the API of .save (or, God forbid, add a new overload / different function just to handle this case), just add a new static if block to check for the UDA, and it Just Works.
In other words, adding a new static if block to .save "teaches" it how to adapt to structs that contain fields that shouldn't be saved. Once thus taught, you can just hand it any type appropriately tagged with the UDA, and it automatically does the Right Thing(tm). No fuss, no muss, no extra parameters, no messy option flags, no massive refactoring of every callsite of .save, just a couple of lines of code change and we're done.
There's so much more you could do to it. For example, another UDA for tagging types that should use a different serialization scheme (e.g., highly-compressible data that should be filtered through a compression algorithm). Another UDA that filters an array to exclude elements that don't need to be saved. Etc.. With every addition, .save becomes more and more powerful, yet its API remains exactly the same as before:
storage.save(data); // <--- exactly the same as before
Code that calls .save don't need to be touched just because some data type now needs different serialization behaviour. No massive refactorings needed, no proliferation of flags and other extraneous parameters to .save. Just tag the type definition, and we're good to go.
This, my friend, is the power of DbI. This example, of course, only scratches the surface of what you can do with DbI, but I'll leave it up to you to explore the rest. :-D
T
--
Nearly all men can stand adversity, but if you want to test a man's character, give him power. -- Abraham Lincoln
|