Thoughts on user-defined types in OpenD

Posted 2024-02-20

auto getting a different type

auto a = [1, 2, 3];
static assert(typeof(a) == int[]);
int[] b = a; // ok, of course
int[3] s = a; // compile error!

int[3] l = [1, 2, 3]; // but this is ok!

In this example, we saw a literal which gives one type when assigned to an auto variable, yet is capable of doing things a normal variable of that type cannot do. There is something similar for string literals:

const(char)* a = "foo"; // ok
auto b = "foo"; // b will be inferred as type `string`
const(char)* c = b; // yet this is not ok

There's a few other similar cases in the D language right now, but it is impossible to define a user-defined type with this same kind of behavior.

We're considering ways to enable this in a user type in OpenD, but haven't actually tried anything yet. One thought that is promising is:

struct S {
	alias opInferenceType = T[]; // perhaps?
}

It would specifically be an alias to a type, which the value is then converted to. Basically, given that S:

auto s = S(); // `auto` changes to `S.opInferenceType`
// so that's the same as:

S.opInferenceType s = S();

This has the potential to be very strange and maybe even change the value entirely once it calls opCast or whatever to do that conversion. I want to think about it some more.

Implicit construction

It was also impossible to write a function that takes a built-in literal where another type was expected.

void foo(int[] a) {}
void bar(string s) {}
void baz(long[X] aa) {}
void test(MyUserDefinedType udt) {}

foo(null); // ok
bar(null); // ok
baz(null); // ok

test(null); // impossible to define in older D

You might also want it on return values.

int[] foo() { return null; } // OK
MyUserDefinedType bar() { return null; } // wanted

OpenD will soon allow this if you mark a constructor with core.attribute.implicit:

import core.attribute;

struct MyUserDefinedType {
	@implicit
	this(typeof(null) n) {}
}

MyUserDefinedType bar() {
	return null; // now ok!
}

void foo(MyUserDefinedType t) {}

foo(null); // now ok too!

The current implementation would allow implicit construction on return even without the @implicit annotation. This is mostly just because the implementation is immature, but it is also perhaps defensible because the return type and the construction are both local to the function. I believe these need less scrutiny than function calls because overload resolution is much less local.

You can always just do return typeof(return)(...) instead of implicit and it makes little difference. That said though, I think I'd prefer to make sure it is consistently opt-in, so I'll probably finish the implementation to check it there too later.

Passing things to functions

The previous sections were about passing a type to a function that explicitly takes another type. But we started this post talking about auto and there's something similar for functions too. Behold:

void foo(T)(T t) {
        pragma(msg, T);
}

void main() {
        immutable(int[]) s;
        foo(s);
}

The T there becomes immutable(int)[], despite it clearly being immutable(int[]) - the outer level immutable got stripped out of the array. Built-in arrays can do this, user-defined types cannot.

This is separate from opInferenceType (or whatever we end up doing) since it will play a different role in function overloading. (Though perhaps it can use the same opInferenceType if constructors were special cased? But that feels weird.)

The proposal I received on this would be opPass. I suspect it'd work very, very similarly to opInferenceType and has some of the same potential concerns.

This is not implemented yet.

Implicit conversions

Implicit construction is when the type being converted to defines the conversion. Implicit conversion is when the converted from defines the conversion.

Implicit construction is important when using literals because you cannot change the definition of the literal types. But for your own user-defined types, you probably want implicit conversions more often, since you can define them right there.

To convert from your type to another in D, there's already one option: alias this. The original intention was to allow multiple alias this, but even this couldn't work for all cases: consider something like my var type from arsd.jsvar, which can convert to almost anything. (Should it be implicit? Well, I think that's a library design decision moreso than one the language should make for me.)

Taking a small patch from user mojo, OpenD will soon allow you to define opImplicitCast.

struct MyUserDefinedType {
	T opImplicitCast(T)() {
		return T.init;
	}
}

MyUserDefinedType udt;

int a = udt; // calls udt.opImplicitCast!int
string b = udt; // calls udt.opImplicitCast!string

void foo(string b) {}

foo(udt); // calls foo(opImplicitCast!string)

This is implemented today. It follows the same overloading rules as other existing implicit conversions, requiring the user to disambiguate if there are multiple candidates (the patch was seriously about 15 lines!).

What if?

These implementations, insomuch as they exist, are immature and not fully thought out. It is not much code, so it mostly leans on existing D rules, but there's still some questions we should ask.

A few that come to my mind:

What if there's both an implicit construction and conversion option? Which one would be preferred to try first? Currently, I favor construction over conversion

What if both are required? If you need an implicit conversion to something that the recipient then allows implicit construction from, is that two-step process acceptable? The current implementation will not allow this.

What about template specialization? The current implementation doesn't touch this. I think this is the correct behavior.

What about reflection? If B can be implicitly constructed from A, should is(A : B) be true? The current implementation says no... but I think this is wrong. A *can* be implicitly converted to B, which is how I read the : in is. So this needs revisiting.

Should you be able to put @implicit on a function param instead of, or in addition to, the constructor itself and have it respected? The current implementation just ignores it everywhere except constructors (it might change to be an error at least later).

What about error messages? How should this interact with template constraints? I think if an opImplicitCast is matched but then errors, that error should propagate up. If it is not matched due to either specialization or a constraint, the error should be swallowed as if opImplicitCast doesn't exist. The current implementation does not correctly realize this, but it makes some effort (better than opDispatch! if i can figure it out for one i'd like to copy the impl to the other).

What other questions should I be asking here that I'm skipping? If you have some, let's talk about it! We welcome your contribution.

And thank you to the four people who contributed directly to the code in the linked PR by email.