By disabling default construction, we can force initialization of a type. Using this feature, we'll make an object reference that will force the user to check for null before using it.
To create a NotNull struct, perform the following steps:
struct NotNull(T)
with a private member of type T
.@disable this();
.T
and assert(t !is null);
.alias this
.struct CheckNull(T)
, with a member T
. The member T
is a property that returns NotNull!T
and has opCast(T : bool)
, which checks the payload for null and returns true
if it is not null.checkNull
, which takes a T
member and returns CheckNull!T
.NotNull!T
whenever possible.NotNull!T
.if(auto nn = test.checkNull) {}
.struct NotNull(T) { private T notNullPayload; @disable this(); private this(T t) { assert(t !is null); notNullPayload = t; } inout(T) getNotNullPayload() inout { return notNullPayload; } alias getNotNullPayload this; } struct CheckNull(T) { private T payload; bool opCast(T : bool)() { return payload !is null; } inout(NotNull!T) getNotNullPayload() inout { return NotNull!T(cast(T) payload); } alias getNotNullPayload this; } CheckNull!T checkNull(T)(T t) { return CheckNull!T(t); } // test usage in a function void test(NotNull!(int*) nn) { import std.stdio; writeln(*nn, " was not null"); } void main() { import std.stdio; int item; int* ptr = &item; // how to do the test, also try if ptr is null if(auto nn = ptr.checkNull) { test(nn); } else { writeln("Null"); } }
There are three key points in this implementation: alias this
, which allows us to control implicit conversions; the disabled default constructor, which forces initialization of any NotNull
member or local variable; and the helper type with opCast(T:bool)
, which enables the type transformation inside the if
statement.
We use a getter property with alias this
instead of going directly to the member, because the getter property prohibits assignment. If we used alias this
directly with the property, it would allow the assigning of null
:
NotNull!Object o = …; o = null; // this would be allowed because alias this would rewrite it to o.payload = null, bypassing our check!
The getter property uses inout
to provide a start to const-correctness.
We use @disable this();
to force initialization of the object. The syntax for a default constructor is this()
. While D's structs cannot have default constructors, the language nevertheless lets you write a prototype specifically to disable it. All variables in D are initialized to default values upon declaration (unless you specifically skip initialization with =void
at the declaration point). The default value of a reference is null
. Thus, to ensure that a NotNull
structure is never null
, we must force explicit initialization. The language will require an initializer at any local variable declaration and an initializer in the constructor if NotNull
is used as an object member.
Moreover, we made the constructor private, forcing the initialization process to go through our helper functions and type. This gives us control similar to languages with special nullable types built into the language. It forces a check and gives us an opportunity to limit the scope of the not null type to the correct if
branch.
Next, the CheckNull
type and the helper constructor serves as an intermediate between normal, nullable types and the NotNull
type, with its guarantees. On using alias this
, it allows implicit conversion to NotNull
upon request (which then performs an additional check for null
at assignment time via assert
in non-release mode). However, first, the opCast
allows it to be checked inside an if
statement.
The compiler converts if(a)
to if(cast(bool) a)
. The opCast
method gives us control over explicit casting to any type, which is given as a compile-time parameter. Here, we specialize on the argument of bool
since that is the only operation we care about with this type. It returns true
if the object is not null. Then, using a variable declaration inside the if
statement, we assign the new type to a local variable and can then use it or pass it to functions as NotNull!T
. This, in turn, implicitly converts back to the original type T
, allowing easy access to all the original type's methods and operations. A not null reference is always substitutable for a possibly null
reference.
The CheckNull
type is necessary in addition to the NotNull
type because CheckNull
might be null
, whereas NotNull
is guaranteed not to be. Adding opCast
to NotNull
is tempting to simplify the code, but then we'd have to remove the assertions for the if
branching to work, thereby breaking the not-null invariant. It would let you write NotNull!T test = checkNull(a_null_reference)
without compile-time and runtime null checking.
It is possible to write Object o = null; NotNull!Object obj = checkNull(o);
. This will compile despite the lack of an if
statement to test null, triggering a runtime assertion failure. This is the best we can do with a library type, but note that this is easy to catch upon code review and it is still an improvement over not using NotNull
at all, since it will trigger the error immediately at assignment time instead of some indeterminate point later in the code when the reference is finally used. The difficult part of debugging null dereference errors is often figuring out why the value was null
in the first place. The assertion failure at assignment time will significantly narrow down the possibilities.
3.147.74.211