Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add optional error message to only to simplify usage #43729

Closed
wants to merge 5 commits into from
Closed

Add optional error message to only to simplify usage #43729

wants to merge 5 commits into from

Conversation

tdunning
Copy link

@tdunning tdunning commented Jan 9, 2022

Uses of only are often replaced (or just not done in the first place) with explicit logic to get better error messages. Allowing the error message to be passed in would prevent this and make only more useful.

@tdunning
Copy link
Author

tdunning commented Jan 9, 2022

This just came up on slack where Mosè Giordano pointed out this line should use only. Unfortunately, that loses the nice error message

https://github.com/JuliaPackaging/BinaryBuilder.jl/blob/4d263be3295dad597cc8e16d0b57374ccb06ace0/src/AutoBuild.jl#L1443

This tiny patch would allow the error message to be included in the call to only thus getting the concision of only with the kind error message.

@DilumAluthge
Copy link
Member

Are we planning on dispatching on the type of message? If not, it seems like this might be better suited for a keyword argument?

@DilumAluthge
Copy link
Member

Also, it seems to me that the must contain exactly 1 element part should always be kept in, and we just append the user's custom message if provided.

@bramtayl
Copy link
Contributor

Maybe a macro would be more generalizable? Then you could throw a warning, or print a message, too?

only_one = @only things otherwise

@mcabbott
Copy link
Contributor

The downside of this is that any message will be created unconditionally, e.g. this string interpolation will happen even when there is no error:

only(uuids, """
    Multiple UUIDs found for package `$(depname)`.
    Use `PackageSpec(name = \"$(depname)\", uuid = ..." to specify the UUID explicitly.
    """)

I'm not sure that matters there, but in tighter loops 100ns or whatever will hurt. It could solve this by being @only I guess.

Stacktrace:
[...]
```
"""
@propagate_inbounds function only(x)
@propagate_inbounds function only(x, message="must contain exactly 1 element")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@propagate_inbounds function only(x, message="must contain exactly 1 element")
@propagate_inbounds function only(x; hint::String="")

i = iterate(x)
@boundscheck if i === nothing
throw(ArgumentError("Collection is empty, must contain exactly 1 element"))
throw(ArgumentError("Collection is empty, $message"))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throw(ArgumentError("Collection is empty, $message"))
str = "Collection is empty, must contain exactly 1 element"
if hint !== ""
str = "$(str); $(hint)"
end
throw(ArgumentError(str))

end
(ret, state) = i::NTuple{2,Any}
@boundscheck if iterate(x, state) !== nothing
throw(ArgumentError("Collection has multiple elements, must contain exactly 1 element"))
throw(ArgumentError("Collection has multiple elements, $message"))
Copy link
Member

@DilumAluthge DilumAluthge Jan 10, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throw(ArgumentError("Collection has multiple elements, $message"))
str = "Collection has multiple elements, must contain exactly 1 element"
if hint !== ""
str = str * "; " * hint
end
throw(ArgumentError(str))

@@ -1372,10 +1372,11 @@ IteratorEltype(::Type{Stateful{T,VS}}) where {T,VS} = IteratorEltype(T)
length(s::Stateful) = length(s.itr) - s.taken

"""
only(x)
only(x, [message])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
only(x, [message])
only(x; hint::String = "")


Return the one and only element of collection `x`, or throw an [`ArgumentError`](@ref) if the
collection has zero or multiple elements.
collection has zero or multiple elements. An optional error message can be provided to customize
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
collection has zero or multiple elements. An optional error message can be provided to customize
collection has zero or multiple elements. An optional error hint can be provided to customize

Stacktrace:
[...]

julia> only(('a', 'b'), "my error message")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
julia> only(('a', 'b'), "my error message")
only(('a', 'b'); hint = "my error hint")

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bramtayl I think that macros are generally more flexible, but I think that would be an addition outside the scope here.

@mcabbott Good point about performance. I think that @DilumAluthge 's changes are constructive in the spirit of avoiding unnecessary interpolation.

@DilumAluthge Regarding your changes to move to keyword arguments, is there actually a significant win over a default value approach? I can't see that this is a hotbed of multiple dispatch, although one could imagine that it might become such (for instance, passing in a fully constructed exception to raise instead of the normal ArgumentException) but I can't see a big difference either way. If you feel there even a modest wind blowing toward the keyword approach, I am fine with it. I would tune the example slightly. I might also go further by adding message and hint.

Copy link
Contributor

@mcabbott mcabbott Jan 10, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that making it a keyword will change the interpolation cost. The user's string is still made before the function is called.

What could perhaps avoid that without a macro is to accept e.g. only(xs, "expected ", typeof(x), " to have one element "), crudely:

only(things, message...) = length(things)==1 ? things[1] : error(string(message...))

But that also seems a bit weird, and you have to remember to write it that way.

@DilumAluthge
Copy link
Member

I'm not sure if this is the right approach. Would it be better to instead use the Base.Experimental.register_error_hint interface for this?

cc: @timholy

@DilumAluthge
Copy link
Member

DilumAluthge commented Jan 10, 2022

It would be good for triage to take a look at this.

Here are some specific questions for triage to consider:

  1. Is it appropriate for this functionality to be added to Base, or would it be better suited for a package?
  2. Should this functionality be added to the existing only function, or should a new @only macro be created?
  3. Should the message/hint be passed as a positional argument or a keyword argument?
  4. Should the message/hint be restricted to String, or should any type be allowed?
  5. Would this be a good use case for the experimental Base.Experimental.register_error_hint interface?

@DilumAluthge DilumAluthge added the triage This should be discussed on a triage call label Jan 10, 2022
@tdunning
Copy link
Author

I noticed just now that there are more forms that need handling.

The question about whether we dispatch on the presence of the error message is now more interesting. Is it better to dispatch to a different entry point? Or will the extra method that Julia inserts without the default all just melt away under inlining (even down to the conditional on the presence of the message).

@tdunning
Copy link
Author

Question number 5 in particular ran me off the cliff.

My general feeling is that overworking this is probably not worth it. Y'all are being amazingly responsive, but I am sure there are bigger things pending that I wouldn't want held up. If you feel this is worth it, cool. If not, also cool.

@DilumAluthge
Copy link
Member

DilumAluthge commented Jan 10, 2022

An alternate approach might be something like x = only(collection, MyCustomExceptionType::Callable).

Then, you can define Base.showerror for your MyCustomExceptionType type, and have complete control over the error message that is printed.

To address the issue of paying the interpolation cost even when the error isn't thrown, you could do something like this:

depname = "DataFrames"
x = only(collection, () -> MyCustomExceptionType(depname))

And then you don't actually perform the string interpolation until the Base.showerror(io:IO, exc::MyCustomExceptionType) function is called, which will only happen if the error is thrown.


If we switch the order of the arguments to be x = only(MyCustomExceptionType::Callable, collection), that would let people use do blocks as such:

depname = "DataFrames"
x = only(collection) do
    MyCustomExceptionType(depname)
end

@DilumAluthge
Copy link
Member

DilumAluthge commented Jan 10, 2022

Question number 5 in particular ran me off the cliff.

Don't worry too much about question 5. I'm not very familiar with the register_error_hint functionality (which is why I want someone else to answer that question), but my guess is that register_error_hint is probably not going to be relevant for this use case.

But that's why I want someone that's familiar with register_error_hint and its intended use cases to weigh in.

@DilumAluthge DilumAluthge added collections Data structures holding multiple items, e.g. sets iteration Involves iteration or the iteration protocol labels Jan 10, 2022
@fredrikekre
Copy link
Member

This is quite strange API, it doesn't exist anywhere else I think? Something like tryonly(x) that return nothing instead of erroring would be more "base-api-like", and then you can handle the nothing case however you see fit (e.g. custom error message).

@DilumAluthge
Copy link
Member

DilumAluthge commented Jan 10, 2022

Something like tryonly(x) that return nothing instead of erroring would be more "base-api-like", and then you can handle the nothing case however you see fit (e.g. custom error message).

That sounds like a good approach!

@tdunning
Copy link
Author

Allowing a Callable exception argument is quite reasonable although it forces a slightly more verbose idiom.

We don't have to use every cleverness in julia for this simple extension. Folks lived without any way to customize the error message for quite some time.

@tdunning
Copy link
Author

I just reviewed the test results. The tester_linux64 failure that I see is a bit of a mystery. Parallel builds of the same sort succeeded and the test that failed was a process issue.

It seems that the test process just fell over due to external issues.

@c42f
Copy link
Member

c42f commented Jan 14, 2022

This is quite strange API, it doesn't exist anywhere else I think?

I agree, I don't think we should add arguments to functions just to customize their internal error handling, especially if there's a performance cost. Base doesn't have a lot of APIs like this and the ones which do exist are slightly weird anomalies (Eg, Meta.parse(str, raise=false))

However Dilum's do block idea does have precedent in the widely used get() function, and makes this whole idea much more useful: it's not just about error handling, but about giving a nice way to return a default if the collection is not of length one, but without computing the default value up front:

# A way to provide defaults
x = only(collection) do
    101
end

# Works well when the default is a costly computation
x = only(collection) do
    expensive_computation()
end

# Also works for custom error messages
x = only(collection) do
    error("Oops your collection was empty")
end

All the expressions above have equivalents with get(collection, key) do ... end

@JeffBezanson
Copy link
Member

A long time ago we used to have a function version of assert that took a string message argument, and I saw some applications that spent all their time interpolating those error strings. That was not good 😄 I like the do block idea.

@tdunning
Copy link
Author

The do block approach is very nice they way you can either provide a default or throw an error. Very visible.

@JeffBezanson
Copy link
Member

From triage: please go ahead with the function argument (do block) implementation.

@JeffBezanson JeffBezanson removed the triage This should be discussed on a triage call label Jan 20, 2022
@tdunning
Copy link
Author

tdunning commented Jan 20, 2022 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
collections Data structures holding multiple items, e.g. sets iteration Involves iteration or the iteration protocol
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants