Hello, I'm trying to implement a local Range type ...
# getting-started
m
Hello, I'm trying to implement a local Range type which includes the possibility to have nullable values; the idea is that a null end value means infinitely far away. Likewise I'd also love there to also be a null start value, but that's for later.
Copy code
interface OpenRange<T : Comparable<T>> {
    val start: T?
    val endInclusive: T?
    operator fun contains(value: T): Boolean = (
        start?.let { value >= it }
            ?: true
        ) && (endInclusive?.let { value <= it } ?: true)
    fun isEmpty(): Boolean = start != null && endInclusive != null && (start!! > endInclusive!!)
}
and then on something like
Copy code
operator fun LocalDateTime.rangeTo(other: LocalDateTime?) = object : OpenRange<LocalDateTime> {
    override val start: LocalDateTime = this@rangeTo
    override val endInclusive: LocalDateTime? = other
}
But ofcourse, LocalDateTime already has an rangeTo, but yet, this compiles without trouble. But when I call a localdatetime with the '..' operator now with a nullable other time, I get a type mismatch like the following.
so my question is; is it even possible to have the operator fun? Or do I need to create a static constructor and go from there?
e
can you provide a reproducible example? this seems to work fine on Playground: https://pl.kotl.in/lgaanXjm4
surprisingly to me, even
Copy code
operator fun <T : Comparable<T>> T?.rangeTo(other: T?): OpenRange<T>
println(null..null)
works. I suppose it defaults to
T: Nothing
, and
Nothing : Comparable<Nothing>
.
m
well, funnily enough, the code provides is basically the whole example
But I'm not on kotlin 1.6, if that makes a difference
e
it still works if I change Playground's Kotlin version to 1.5.31, 1.4.30, 1.3.72, or 1.2.71 (with a change to
fun main(args: Array<String>)
because arg-less
main
was introduced in 1.3) though, so presumably there's something else going on with your code
or there's a chance it's just the IDE being confused
m
Thanks for the input, it made me find the problem
The interface is written in a common module, and therefor it needed to be manually imported before the IDE understood that the nullable variant was a viable alternative
k
What is the point of having such a range? You might as well use LocalDateTime.MAX. You'll never get anything later than that.
e
dunno about LocalDateTime, but it could be useful for other types, such as String, which does not have a maximal value
well ok in theory
String(CharArray(Int.MAX_VALUE) { '\uffff' })
is probably the maximal value, but that's not practically representable in memory
😆 1
m
Not only can it be useful for situations where there is no known endpoint (think BigInteger, BigDecimal, etc.) Contextually however, you use a null value to represent the absent of a time. Even though, currently, LocalDate(Time) has a known maximum value, there is a contextual different between saying;
It's the maximum value
and
There is no endtime
. It also makes the check and usages of the date itself typesafe. Knowing that the endtime is absent can give you additional information you might lose by just defaulting to max. we'd like to never abstract away information
Other than that; using something like:
Copy code
time in start..(end ?: SpecificTypeEndValueDependingOnType)
is a manual mess, especially if you need to do it in ALOT of situations
this is especially the case if you also consider a nullable starttime
Copy code
time in (start ?: MinValue)..(end ?: MaxValue)
e
I'd rather have separate types for
RangeBoundedAbove
and
RangeBoundedBelow
, with a common supertype, but annoyingly
ClosedRange
can't be made to implement another interface so you'd have to create a type to represent a range bounded on both ends
if you use a separate types to denote empty ranges, you don't need to handle inverted bounds as a special case, and can even handle
object Singleton : Comparable<Singleton> { override fun compareTo(other: Singleton): Int = 0 }
Copy code
interface Range<T> {
    val start: T?
    val endInclusive: T?
    fun isEmpty(): Boolean
    fun contains(value: T): Boolean
}
interface NonEmptyRange<T> : Range<T> {
    override fun isEmpty(): Boolean = false
}
private object EmptyRange : Range<Nothing> {
    override val start: Nothing? get() = null
    override val endInclusive: Nothing? get() = null
    override fun isEmpty(): Boolean = true
    override fun contains(value: Nothing): Boolean = false
}
private object AllRange : NonEmptyRange<Nothing> {
    override val start: Nothing? get() = null
    override val endInclusive: Nothing? get() = null
    override fun contains(value: Nothing): Boolean = true
}
class RangeBoundedBelow<T : Comparable<T>>(override val start: T) : NonEmptyRange<T> {
    override val endInclusive: T? get() = null
    override fun contains(value: T): Boolean = start <= value
}
class RangeBoundedAbove<T : Comparable<T>>(override val endInclusive: T) : NonEmptyRange<T> {
    override val start: T? get() = null
    override fun contains(value: T): Boolean = endInclusive >= value
}
class RangeBounded<T : Comparable<T>>(override val start: T, override val endInclusive: T) : NonEmptyRange<T> {
    init {
        require(start <= endInclusive)
    }
    override fun contains(value: T): Boolean = start <= value && endInclusive >= value
}

fun <T> emptyRange(): Range<T> = EmptyRange as Range<T>
fun <T> allRange(): Range<T> = AllRange as Range<T>
operator fun <T : Comparable<T>> T.rangeTo(other: T): RangeBounded<T> = RangeBounded(this, other)
@JvmName("rangeToNullable")
operator fun <T : Comparable<T>> T.rangeTo(other: T?): NonEmptyRange<T> = if (other == null) RangeBoundedBelow(this) else RangeBounded(this, other)
@JvmName("nullableRangeTo")
operator fun <T : Comparable<T>> T?.rangeTo(other: T): NonEmptyRange<T> = if (this == null) RangeBoundedAbove(other) else RangeBounded(this, other)
m
looks like I gave you quite an assignment. Looks very good @ephemient, I'll propose these things in my PR
Copy code
val test: LocalDate? = null
val test2: LocalDate? = null
val test3 = test..test2
sadly this isn't possible with your example 😛
e
you could define a
@JvmName("nullableRangeToNullable") operator fun <T : Comparable<T>> T?.rangeTo(other: T?): Range<T>
but you'd have to define what the behavior of
null..null
will be; I excluded it as unclear
m
null..null can't exist because you haven't defined what type the null comes from.
at least that's how I looked at it
e
in your
test..test2
case, you do have types. and also it seems that the compiler is happy to treat
null..null
as
Range<Nothing>
, from my experiment up-thread
so if you have a
T?.rangeTo(T?)
function, it certainly can get
null
on both sides. but what should that even mean?
m
in the case of just a clearcut null..null (nontyped, hard filled) it means nothing.. In the case of typed nulls, it basically means a range that contains all of T
e
you just need a
Copy code
@JvmName("nullableRangeToNullable")
operator fun <T : Comparable<T>> T?.rangeTo(other: T?): Range<T> = if (this == null) if (other == null) allRange() else this..other else this..other
if that's what you want it to mean
m
yeah I basically rewrote all the seperate rangeTo until a single when
Copy code
operator fun <T : Comparable<T>> T?.rangeTo(other: T?): Range<T> = when {
    this == null && other != null -> RangeBoundedAbove(other)
    this != null && other == null -> RangeBoundedBelow(this)
    this != null && other != null -> RangeBounded(this, other)
    else -> allRange()
}
I also like the inclusion of an emptyRange represented with nulls, even though I have no usecase for it yet
s
the idea is that a null end value means infinitely far away tingles my spidersenses. In Go that'd be normal because of there idea to make
null
useful, so the dev is used to check up on what the meaning of null for every struct is, but in Kotlin
null
is assumed to be the abscence of a value. Breaking that expectation is a code smell in my book. So I'd wrap the value in a sealed class to handle these special values explicitly:
Copy code
sealed class Value<T: Comparable<T>>
class Finite<T: Comparable<T>>(val value: T): Value<T>()
object NegInfinity: Value<Nothing>()
object PosInfinity: Value<Nothing>()

interface OpenRange<T, V : Value<T>> where T:Comparable<T>, T: Any {
    val start: V
    val endInclusive: V
    // ...
}
And suddenly you can even discern between positive and negative infinitiy.
e
adding two ideal points works for BigInteger, but doesn't make sense for String which would be better off with an Alexandroff extension, as there's only one missing limit
anyhow if you're taking that approach it would make more sense to
Copy code
sealed class WithLimits<T : Comparable<T>> : Comparable<WithLimits<T>>
operator fun <T : Comparable<T>> ClosedRange<WithLimits<T>>.contains(value: T)
reusing the built-in
Finite(x)..Finite(y)
and just enhancing
in
m
@Stephan Schroeder I see where you are coming from but I have to respectfully disagree. Maybe naming it "OpenRange" is where it goes wrong; but the interface represents a unbounded infinite line in 2D for a specific type. The interface could exist without those two nullable variables and would still hold this meaning. Then, when you introduce a start, you are basically saying you are putting a point on that line, and cutting off the rest. The same goes with putting an end on that line. The concept of null being an absent value, just means that the bounds are absent.
I've opted to rename the interface and subsequent implementation to the concept of Lines, Segments and Rays
s
just to get rid of the
!!
Copy code
fun isEmpty(): Boolean {
    val startPoint = start ?: return true
    val endPoint = endInclusive ?: return true
    return startPoint > endPoint
}