Why is there no isless(::Int64, ::UnitRange{Int64})?

This is the approach taken by Intervals.jl, yes.

julia> using Intervals

julia> 2 < 3..5
true

julia> 4 ≤ 3..5
false

Although it seems we don’t agree about the meaning of here.

It’s also missing some generic capabilities which would be critical given how I discovered the topic of this thread:

julia> "abcde"[2..3]
MethodError: no method matching getindex(::String, ::Interval{Int64, Closed, Closed})

That one might be worth a PR actually. Not clear if it’s in keeping with the spirit of the package, but it could be rather nice, I’ve wanted the ability to work with semi-open intervals fairly often when doing stringy things.

The question isn’t really about how to code around the lack of an isless here, that’s very straightforward.

I also want to point out that, as I mentioned in a previous post, defaults to a < b || a ==b. So defining isless(i, range) wouldn’t let slip the dogs of war.

1 Like

I’m willing to concede that my interpretation of is not obvious to everyone (that’s quite clear), so how about just this:

julia> Base.isless(i::Integer, r::UnitRange{<:Integer}) = i < r.start

julia> Base.isless(r::UnitRange{<:Integer}, i::Integer) = r.stop < i

julia> 2 < 3:5
true

julia> 6 > 3:5
true

julia> 4 ≤ 3:5
false

That seems hard to argue with as a meaningful result.

It would do some slightly perverse things with an empty range, but adding isempty(r) ? false : ... would sort that out.

And there’s your answer as to why it’s not defined in Base.

Note that 1:3 == [1,2,3] — ranges are vectors.

6 Likes

Well hold on a minute, I just demonstrated that defining isless doesn’t create the problematic equivalence relation!

So I think that’s pretty strange, tbh. Why not this one then?

julia> "abc" == ['a', 'b', 'c']
false

After all:

julia> map(UInt8,['a', 'b', 'c'])
3-element Vector{UInt8}:
 0x61
 0x62
 0x63

julia> codeunits("abc")
3-element Base.CodeUnits{UInt8, String}:
 0x61
 0x62
 0x63

But I suspect the answer is “people find it useful, so we defined it that way”.

What’s the utility of 1 < 2:3 throwing an error?

Ranges are subtypes of AbstractVector. Strings aren’t. Yes, there are lots of alternate universes here that are equally-well founded. But these are choices that have already been made, and there’s not a consensus on what makes “obvious” sense in this universe as seen in the posts above.

13 Likes

Fair enough. I could keep bickering about whether or not this is ideal, but I was looking for an explanation, and that suffices.

(Note that String cannot be a subtype of AbstractVector, even if we wanted this, because string iteration returns characters and characters cannot have consecutive indices with random access in the UTF-8 encoding — this would break the AbstractVector abstraction by either having non-consecutive indices or non-O(1) indexing. A codeunits(string) view, on the other hand, is a subtype of AbstractVector.)

6 Likes

Is it specified somewhere that iteration and indexing are in the same order for AbstractVector?

I don’t know if this is written down explicitly anywhere in the manual, but it should be — I think has always been understood that array iteration is equivalent to eachindex iteration (and in fact is implemented this way for AbstractArray). (It’s arguably implied, at least.)

(Regardless, it is documented that iteration (for a in A) yields the indexed elements a[i] of the array.)

Julia has never had a formal written specification (DOC: language specification · Issue #4144 · JuliaLang/julia · GitHub), so there are a lot of things (like the array interface) that are mathematically underspecified by the documentation, but for which the behavior (as defined by the julia implementation) is not really a subject of debate.

5 Likes

Possibly relevant: there’s an IEEE standard for interval arithmetic, IEEE SA - IEEE 1788-2015, which is implemented by IntervalArithmetic.jl. See also the organization’s site at https://juliaintervals.github.io/ for more context.

3 Likes

There is a nice Eric Lippet (a designer of C#) that he used to answer someone answer someone asking “Why doesn’t C# do x?”

which was that “Every feature starts at -100 points”.
Meaning: not only does something have to be simply “good/useful” to be worth adding to the language/stdlib, it has to be substantially so.
Because every feature adds complexity to both learning and maintaining, and takes time to implement.

7 Likes

I also feel like the sentiment in On the arbitrariness of truth(iness) applies here. And in much the same way Stefan concludes “Just say no to truthiness”, I would say “Just say no to (in)equality comparisons between numbers and collections of them.”

4 Likes

Interesting! I passed this on to a colleague who is building something which stands to take advantage of it. While accurate, it’s not especially ergonomic:

julia> 2 < 3..4
ArgumentError: `<` is purposely not supported for intervals. See instead `isstrictless`, `strictprecedes`

Fair enough, I’d like some operators though, Julia’s Unicode support makes a bunch available.

julia> isstrictless(3.0, 4..5)
MethodError: no method matching isstrictless(::Float64, ::Interval{Float64})

Well that’s annoying!

julia> isstrictless(convert(Interval{Float64},3.0), 4..5)
true

Seems like a DWIM moment to me…

I’ve learned not to predict the outcome of this sort of post on Discourse, but I thought the mostly likely answer to my question was going to be “see issue #xxxx, especially [this comment]”. Which might still show up! But unless it does, it seems this particular question hasn’t had its day in the sun. (I did search the issue board before posting, but that means I checked a couple keywords, it certainly isn’t comprehensive).

An implementation for isless(::Integer, UnitRange{<:Integer}) is a one-liner, implementation cost and maintenance burden are a non-issue here. Yes, a policy of “any one-liner is fine” results in a bloated and labyrinthine codebase, it still has to pull its weight. But if we’re starting at -100, “this is almost trivial” should be worth +50 at least.

I consider this the best argument against it:

julia> Base.isless(r::UnitRange{<:Integer}, i::Integer) = r.stop < i

julia> Base.isless(i::Integer, r::UnitRange{<:Integer}) = i < r.start

julia> 2 < 1:4
false

julia> 2 == 1:4
false

julia> 2 > 1:4
false

Although I would go up one level here, and say that where the documentation says:

If isless(x, y) is defined, then so is isless(y, x) and isequal(x, y), and exactly one of those three yields true.

The more mathematically-sound interpretation of < is “at most one”, not “exactly one”. This does have implications for sorting: to sort a mixed collection of <: Integer and UnitRange{<:Integer}, one would have to supply an lt which determines which side of the fence (2, 1:4) should go on, or suffer an unstable sort.

But this, of course, reflects reality: 2 < 1:4 is decidedly false, but where one would put the two of them in a sort needs to choose another operation. IntervalArithmetic.jl makes it quite clear that sorting intervals and interval-like constructs takes resolution of several arbitrary degrees of freedom: how would you sort the pair (2 ± 2, 2 ± 4)?

The pragmatic construct which solves the issue in my codebase which prompted the question is simply a < minimum(b), which works “correctly” for any Integer a and any of Integer or UnitRange b. The less practical answer would be to make some sort of ordering library with various operators. That would be cool but it isn’t on the short path to anything I want or need, so probably not.

I’ve worked with a lot of truthy languages, and my evaluation is a) Julia makes the right decision for Julia and b) Lua gets truthiness right. It’s a combination of factors. For one, only false and nil are falsey, everything else is truthy, and for another, accessing an unset field on a table returns nil, so if haskey(table, :key) may be spelled if table.key. Lua is a minimalist language, Julia is not. Slightly more cumbersome if clauses are worth the clarity it brings. 0 for false, in particular, is a lamentable confusion of levels which should only be available with a cast.

Which of course, Julia does provide:

julia> Bool(0)
false

julia> Bool(1)
true

The consensus I’m seeing is that, as a subtype of AbstractVector, a UnitRange{T} is primarily a minimal-allocation collection of values, rather than primarily what its struct would suggest, a start and a stop which describe a dense region of the ordering of {T}. Understandable, Julia’s community of practice is heavily array-oriented, and that is the supertype.

I view it as both of these things, and don’t consider “one shouldn’t pick an ordering relation between scalars and vectors” to be a slam-dunk argument against doing so for this particular vector. I would describe a r::UnitRange{T} as “The set or vector of all t in T for which r.start ≤ t ≤ r.stop”. I believe this is entirely correct.

I like to ask “how does this error help the user”?

julia> 4 < [2, 2, 2, 2, 2, 3, 7, 7, 128]
MethodError: no method matching isless(::Int64, ::Vector{Int64})

This error is helpful. Do you mean less than the minimum? less than the median? The mean? Was it just a mistake? Less than the first value because you know it’s sorted?

The same error for 4 < 6:8 seems less helpful to me. It could be a mistake, anything could be a mistake, but if it isn’t, you’ll note that all four suggested interpretations above give the same answer as I propose for isless. I would go so far as to claim that there is no interpretation of a UnitRange available in which 4 < 6:8 would be false. And it enables generic code for both an index and a dense region of indices, a privilege which needn’t be extended to the interpretation of Vector{Int} as a sparse collection of indices.

The digression surrounding at the beginning of this thread was unfortunate; while my interpretation is a sensible one, it’s at variance with the usual meaning of and is clearly confusing to most people. I withdraw the proposal.

I find it annoying for similar reasons that copy isn’t defined for immutable types. That error is useless, as is the branch which repairs it: “give me an independent version of this data” is operable on an immutable type, even though it happens to be “the same”. It makes things like using a macro to define a copy function for a mutable struct pointlessly difficult, the error isn’t helping the user.

My only dissatisfaction with int < minimum(int_or_range) is that it’s overly broad! It would “work” for a bunch of data types where I don’t intend the comparison to be meaningful. But then again, given that Julia has a type system, this issue can be completely avoided in all practical cases. So it will do.

Maybe. But note that with multiple dispatch, you can ask a practically infinite number of “why isn’t f(::T, ::S) defined?” questions, so this does not mean much.

One great thing about Julia Base (and most libraries) is that they did not err on the side of defining some arbitrary semantics just because they could. A MethodError is infinitely preferable to a silent bug.

3 Likes

It was an observation, certainly not a criticism. It matters whether this is a reboot of a settled question or whether it’s simply never been addressed before.

True of course, but that comes back to my question “how is this error serving the user?”. No one wants Julia to arbitrarily pick semantics for every method in a misguided pursuit of full genericity. This is about one (two really) specific methods.

To summarize, the best argument for it is that a::T < (b:c)::UnitRange{T} would always be completely unambiguous and unsurprising. The best argument against it is that the manual has adopted “exactly one” semantics for <, and this has “at most one” semantics. To which my counter is that in mathematics, < is an at-most-one operation, and comparing a scalar value to an interval is only one of several cases where this is true.

One should note that strict partial orders use < without the requirement of antisymmetry, which is the “exactly one” I refer to in the documentation.

Colloquially, inequality doesn’t require equality to be a meaningful concept. Julia is overly strict here, when compared to notation.

You keep talking about intervals, but there is no connection between vectors, like unit ranges, and intervals. If I were going to suggest a comparison that made a bit of sense it would be

<(x::Number, v::AbstractVector) = abs(x) < norm(v) 

or something along those lines.

3 Likes

Perhaps they’d be receptive to a PR? Arithmetic operations promote, I don’t know if there’s any reason comparisons shouldn’t.

julia> x = 1.0 + (4.0..5.0)
[5.0, 6.0]_com_NG

julia> 2x
[10.0, 12.0]_com_NG

Note that isless and < are different functions: isless should implement a total order, while < can implement a partial order (but falls back to isless). See Mathematics · The Julia Language

New types with a canonical partial order should implement [<] for two arguments of the new type. Types with a canonical total order should implement isless instead.

3 Likes

Great catch! I would say that puts the following on solid ground:

julia> Base.:<(i::Integer, r::UnitRange{<:Integer}) = i < r.start

julia> Base.:<(r::UnitRange{<:Integer}, i::Integer) = r.stop < i

It isn’t really debatable that this constitutes a valid partial order. I got thrown off by the special handling of NaN and managed to miss that, despite it being clearly stated in the documentation for both isless and <. Live and learn.

This is under discussion on a different channel :slight_smile: it isn’t really in my wheelhouse, but my comment probably came off sharper than intended.

1 Like

One should better consider things like

Base.all(f::F, r::AbstractRange) where {F<:Base.Fix2{typeof(<)}} = begin
    @assert ! isempty(r) # define me ! w trait dynamically according to r (currently), statically according to f ?!
    f(r[1])
end

using Test
@test all(<(3), 1:2)

and

for (fn,ind) in (
    (:(<),1),
    (:(>),2))
    # ...
    @eval Base.all(f::F, r::AbstractRange) where {F<:Base.Fix2{typeof($fn)}} = begin
        @assert ! isempty(r) # idem
        f(r[$ind])
    end
end

using Test
@test all(<(3), 1:2)
@test all(>(1), 2:3)
1 Like

A new definition of all is not needed. That already works, does it not?

$ julia -E "all(<(3), 1:2)"
true
1 Like