Struct-typing in Phobos
Chris Williams
yoreanon-chrisw at yahoo.co.jp
Mon Feb 3 18:04:54 PST 2014
I've been working on adding dcrypt to Phobos, which meant
considering what to do in terms of taking it easy and simply
pushing it in as-is, or converting all of the classes and
interfaces into structs with type checkers (I'll be doing the
latter). But it made me consider the pros and cons of the two
options.
Basically, it seemed to come down to:
Pro-structs
1. Smaller, faster
2. Adds unique D-ish flavor to the standard library
Anti-structs
1. Difficult to read. E.g. a beginning programmer needs to be
able to look at this and understand it before he can use the
library:
size_t insertBack(Stuff)(Stuff stuff) if
(isImplicitlyConvertible!(Stuff, T) || isInputRange!Stuff &&
isImplicitlyConvertible!(ElementType!Stuff, T));
And of course, even being able to read it, one would still need
to track down isInputRange() and isImplicitlyConvertible() to
know what they do.
2. When using classes and interfaces, type checking is as simple
as writing the type to be passed in as a parameter. With
struct-typing, people need to manually append a series of checks
all over the place, which results in things like the following:
auto uniform(T, UniformRandomNumberGenerator)(ref
UniformRandomNumberGenerator urng) if (!is(T == enum) &&
(isIntegral!T || isSomeChar!T));
Even though isUniformRNG() exists and is used on other
implementations of uniform(), it isn't used here, because whoever
developed the function forgot to include it on this particular
function.
3. Non-extensible. To "extend" an SList or whatever, I would have
to write a wrapper class with methods that forward to the SList
methods, if I wanted my object to be able to interoperate with
Phobos, or I would need to modify Phobos so that the body of
SList was a template that could be mixed in (assuming I didn't
want to override any methods, just add new ones).
4. Unclear how great an advantage the smaller/faster aspect
actually gives us relative to the demerits of this style of
coding. For example, using code from the below writeup (see
below), I tested the performance of inserting 100,000 items then
removing them all 100 times with both the interface/class and
struct versions, for a total time of 1905±4ms / 1930±10ms (with a
slightly smaller test the struct won, suggesting that there's no
real difference). My suspicion is that the compiler/linker was
detecting that there was only a single implemntation with no
subclasses, hence it compiled out to something roughly equivalent
with no vtable, so I split class implementation of HashSet into
two, with an abstract base class that contained the "data"
variable and nothing else, with all the methods declared in the
subclass and that bumped the runtime to 1980±10ms. But still
that's only a 2.5% difference in speed and only if one goes
gung-ho with layering classes.
And for many objects - like random generators or HashSets - you
aren't going to have masses of instances of the same type, just
one top-level instance that internally contains basic data types
(like structs), so there likely won't be much of a memory
difference for most applications either.
Personally, I think that a better method of type-specialization
needs to be added to the language (e.g. allowing isX!T statements
to be used as types where we write types rather than preprocessor
functions or allowing structs to implement interfaces) so that
post-fixed "if" statements on function declarations are more of a
last-ditch effort, rather than the norm. Though as pointed out,
it might not be worth it unless someone can point out truly
significant performance advantages.
But certainly for the moment, at least having an article about
these things on the top site seems advisable, given how
frequently we see them in the standard library. Plus, users and
Phobos developers might want to have a quick tutorial to make
their own.
So here's a quick write-up:
----
The core library of a language should be small, fast, powerful,
and generic. This last item, however, causes a problem for
library architects. Traditionally, the more generic something is,
the larger and slower it is - with several layers of abstraction
and indirection to allow many disparate implementations of a
generic idea to all serve under the same interface. D supports
all of the features that would allow one to write a library like
this, but it also supports features that allow one to write a
library which does not sacrifice performance for generality.
For example, here would be a standard declaration of a Set
interface and a simple implementation as a class:
interface Set(T) {
size_t length();
size_t insert(T);
T front();
T back();
void removeFront();
void removeBack();
}
class HashSet(T) : Set!(T) {
private:
void[0][T] data;
public:
size_t length() {
return data.length;
}
size_t insert(T value) {
data[value] = [];
return data.length;
}
T front() {
foreach (k, v; data) {
return k;
}
return T.init;
}
T back() {
foreach_reverse (k, v; data) {
return k;
}
return T.init;
}
void removeFront() {
if (data.length == 0) return;
T key;
foreach (k, v; data) {
key = k;
break;
}
data.remove(key);
}
void removeBack() {
if (data.length == 0) return;
T key;
foreach_reverse (k, v; data) {
key = k;
break;
}
data.remove(key);
}
}
When we write a function which accepts a Set, we can write:
void foo(Set someSet) {
someSet.insert(a);
writeln(someSet.front);
}
This will accept and operate on any class which implements the
Set interface (as you would expect).
To reduce overhead, in the D standard library, it is standard to
try and implement this as a struct instead of as a class. But if
we implement HashSet as a struct (which cannot implement an
interface), then we would traditionally have no way to write
functions which can accept generic types, like Sets.
This is solved firstly by use of the templating system. If I have
a function which accepts a templated type, then so long as the
code compiles once the type has been resolved, any arbitrary
class or struct which implements the correct functions will
compile and operate correctly.
void foo(Set)(Set someSet) { // "Set" is just a templated type
name
someSet.insert(a); // Only compiles if Set resolves to a type
with insert and front defined
writeln(someSet.front);
}
In a sense, this is a method for duck-typing in D, though not a
very good one since if we look at the function declaration, it is
not clear what types are or are not valid as parameters to foo().
We must look through the implementation of foo() to find useages
of someSet to determine what is required. Likewise, there is
nothing like "interface Set" which is mandating a standardized
set of methods that all of our functions can expect and which
developers of new implementations of Set types can know to
implement.
This is corrected by use of compile-time duck-type checking
templates. First we declare a template which implements a static
test of compilability of the type for our chosen interface.
template isSet(Set) {
enum bool isSet = is(typeof(
(inout int = 0)
{
Set!int s = void; // can define an instance
if (s.length == 0) {} // can test size
s.insert(1); // can insert
auto h = s.front; // can access front
h = s.back; // can access back
s.removeFront; // can remove front
s.removeBack; // can remove back
}));
}
Following this, we first want to validate that our implementation
is compliant.
struct HashSet(T) {
static assert( isSet!(HashSet!T) );
...
}
We can then upgrade foo() to also require only types which
conform:
void foo(Set)(Set someSet) if (isSet!Set) {
...
}
While more verbose, this sort of duck-typing offers advantages
beyond just speed. As it tests only 'compilability', options open
up to allow flexibility in your definitions. One such example,
the random number generators in std.random are defined so that
they can be treated like InputRange types. Any InputRange is
expected to return whether it is empty or not, but as a random
number generator never becomes empty, in std.random the "empty"
value is a constant (enum) attribute rather than a method.
Another similar benefit is the ability to allow undefined return
types. For example, asymmetric cryptography algorithms should
always generate Public and Private keys, but the values that
comprise those keys is dependent on the algorithm used.
RSAPublicKey and ElGamalPublicKey are basic data structures, not
actionable classes. There is no reason to have a shared base
class or common interface. With D's template-based duck-typing,
one can validate the existence of public/private key accessors
(using "auto" to hold the return values), without caring whether
the return type for different implementations have the same or
even interchangeable types.
More information about the Digitalmars-d
mailing list