I'm building a library that has a very large DSL. ...
# language-evolution
c
I'm building a library that has a very large DSL. Operators in this DSL are usually binary and their operands must respect some type criteria. For example:
Copy code
interface Value<T>

infix fun <T> Value<T>.eq(other: Value<T>): Value<Boolean>
So far, this is quite simple and well-solved by Kotlin. However, there are types which can be seen as
Value<T>
which don't implement the interface, because I don't own them. In this particular case, there are 4: • instances of the interface
Value<T>
, for example as returned by other operators • instances of
T
(the regular Kotlin type) • instances of
KProperty1<*, T>
• instances of
Field<T>
(another type, describing what it is is not relevant to this discussion) Solution 1. Overloads Therefore, to typesafely accept all of these values, I would have to declare all operators 8 times (since they accept two operands which can each have 4 different types). Since there are more than 30–40 operators, that would result in a lot of declared extension functions, which is probably not great for compilation performance.
Copy code
someValue eq true
Solution 2. Conversion method An alternative solution that is currently possible is to have the user call a conversion method to convert non-
Value<T>
types. For example, it could be called
of
.
Copy code
someValue eq of(true)
This is, by far, the simplest solution at the moment. However, it implies a burden on the user, and calling
of
everywhere can make usage quite verbose:
Copy code
of(User::score) set cond { of(User::role) eq of(User::candidate) }
    .then { of(1) }
    .else { of(User::score) add of(1) }
I think you'll agree that this is quite a bit harder to read than
Copy code
User::score set cond { User::role eq User::candidate }
    .then { 1 }
    .else { User::score add 1 }
Because it burdens the user, I don't think this solution is what library authors should use at the moment. However, it can be a part of a larger solution. For example, having these conversion functions makes the overload solution trivial to implement, as all overloads just delegate to the main one. Proposed solution 3. Proper union types Based on the way the problem is described, the operand types could be declared as
Copy code
union Operand<T> = Value<T> | T | KProperty1<*, T> | Field<T>
There already exists a lot of discussion on union types, so I won't detail this solution further. Proposed solution 4. Fake union types This idea is similar to union types for callers, but it doesn't require union types to exist at runtime. The idea is to coalesce all cases into a single regular type through a conversion function, and Kotlin compiler just has to call the provided conversion functions. In this example, the canonical type is
Value<T>
, so we could imagine a new operator that converts from other type.
Copy code
interface Value<T> {
    // …

    operator fun from(other: T) = …
    operator fun from(other: KProperty1<*, T>) = …
    operator fun from(other: Field<T>) = …
}
In a way, this is very similar to C++'s implicit conversion methods, but declared in reverse: instead of a type describing what it can be converted into, a type described what it can be converted from. I believe this is much safer and less surprising, but it may still be much too surprising. This could also be very badly abused if the conversion methods aren't pure (in an FP sense). Proposed solution 5. Typeclasses I know that other languages have used typeclasses to solve this issue. To be honest, I'm not fully familiar with how they work, other than "typeclasses are interfaces that you can implement remotely". Since typeclasses have been proposed to Kotlin in the past without success, I assume situations similar as this were already discussed and this message won't bring them back on the table. Also, I have heard multiple people mention context parameters could help emulate them, so maybe there is an additional context parameter solution here? But I'm not seeing it. That's about all the options I thought of. Are there other existing ways to solve this use-case? If not, are there other potential solutions being discussed? I'm curious how this could move forward.
y
KProperty1
is an interface, so could you maybe have
Field
and
Value
implement
KProperty1
? That'll take down the amount of overloads to just 4, which should be doable
c
That was the initial design, but
KProperty1
can't be implemented in KJS 😕
Also, while my types have two type parameters (I only put one in the examples because they don't matter to the problem), they are very different from
KProperty1
's, so I think reusing that as the top of the hierarchy will be very confusing.
cc @Alejandro Serrano.Mena what do you think? How would you solve this?
r
It looks to me that extension interfaces could be a good solution here, if Kotlin had this feature. You could write:
Copy code
interface MyAbstractValue<T> {
   fun asValue(): Value<T>
}

impl MyAbstractValue<T> for Value<T> {
  fun asValue(): Value<T> = this
}

impl MyAbstractValue<T> for KProperty<*, T> {
  fun asValue(): Value<T> = ...
}

// and so on for all 4 types
and then, you only have to implement you operators once for
MyAbstractValue<T>
type.
1
y
The other approach is indeed to use context parameters:
Copy code
interface Value<T>
interface Field<T>

interface ValueLike<in T> {
    fun T.toValue(): Value<*>
}

object KotlinValueLike: ValueLike<Any?> {
    override fun Any?.toValue() = TODO()
}

object KPropertyValueLike: ValueLike<KProperty1<*, *>> {
    override fun KProperty1<*, *>.toValue() = TODO()
}

object FieldValueLike: ValueLike<Field<*>> {
    override fun Field<*>.toValue() = TODO()
}

context(_: ValueLike<A>, _: ValueLike<B>)
infix fun <A, B> A.eq(other: B): Value<Boolean> = TODO()
// this introduces your DSL to the user, and automatically brings in the objects
fun myDsl(block: context(KotlinValueLike, KPropertyValueLike, FieldValueLike) = TODO()
However, there is no way to extract the type parameter from
KProperty
,
Field
etc without requiring a
ValueLike
object for every such type (i.e
ValueLike<F, T> { fun F.toValue(): Value<T> }
, which means you need a
ValueLike<Int, Int>
, a
ValueLike<KProperty1<*, Int>, Int>
, etc). This could be "fixed" (i.e. not require user to have an instance for every type) either by having Higher-Kinded types in the language, or by having some form of automatic bringing in of contexts by calling some function automatically (I can expand on what that'd look like, but Arrow had something similar sketched out)
c
@Youssef Shoaib [MOD] you're talking about type proofs in Arrow Meta, no? If so, yeah, my "fake union types" proposal is essentially that, yeah.
y
Type proofs would do automatic conversions everywhere. What I'm on about is the
Given
feature, which would allow one to
summon
context parameters automatically if one isn't in scope. Using it, you could write:
Copy code
interface ValueLike<F, T> { fun F.toValue(): Value<T> }
@Given
fun <T> kPropertyValueLike(): ValueLike<KProperty1<*, T>, T>
and similarly for the 3 other types, then you can have e.g.
Copy code
context(_: ValueLike<F1, T>, _: ValueLike<F2, T>)
infix fun <F1, F2, T> F1.plus(other: F2): Value<T>
and this basically has the full power of type classes, but in a more Kotlin-friendly packaging (and it also allows local instances of a type class to be brought in)
👀 1
a
@CLOVIS I've had similar situations in building DSLs, and at the end I tend to prefer a solution with (1) + (2), that is, making "injection into the new type" easy, and having overloads for the most common cases (for example, sometimes I have
eq
which can take booleans and numbers, but only for the second argument, since most times the first one is already a
Value
). On that note, I think that extension properties can help a lot here, so you can write
User::role.v
or something similar. On a more conceptual level, I think that Kotlin tries to emphasize explicitness in this kind of situations. For example, value classes must be constructed and deconstructed explicitly, and Kotlin lacks conversion methods (as opposed to Scala). In that regard, I would go even further than creating a single
of
and have a bunch differently named arguments
prop(User::score) set cond { prop(User::role) eq prop(User::candidate) }
Finally, you raise a concern about having too many overloads for solution (1). I don't think this is a problem, since checking whether those overloads match or not is relatively cheap (no complex hierarchies involved).
c
For example, value classes must be constructed and deconstructed explicitly, and Kotlin lacks conversion methods (as opposed to Scala)
I think that's important because one of the main use-cases of value classes is to restrain the type by adding validation in the constructor, and having an exception thrown from implicit code would be hell. However, in my case, the conversion is 100% guaranteed to succeed and be pure. I kinda feel like it would be nice to have special treatment here, but also I can't see a way to add this to the language without this feature being horribly abused everywhere.
a
one trick that is played, e.g., in
kotlinx.html
, is to have a context in which you can perform your injection with a very simple character, such as
+
or
-
Copy code
value {
  +User::score set cond { +User::role eq +User::candidate }
    .then { +1) }
    .else { +User::score + 1 }
}
c
Yeah, that's essentially the same as the
of
helper but with a
unaryPlus
, right? I don't think many people like the look of this though… Feels more like operator abuse than proper design
2
I'll admit it is quite readable though
ouch, I just found an operator that has 3 operands, so 12 overloads…
Actually I'm wrong. 2 operands mean 4²=16 overloads, and 3 operands mean 4³=64 overloads. That's a lot, no? That's already going to be a pain to maintain, but are you sure this won't cause performance problems? I have about 70 functions that need this treatment.