Skip to content

Conversation

@jacobthemyth
Copy link
Contributor

@jacobthemyth jacobthemyth commented May 9, 2024

This is just a quick spike that demonstrates the possibility of using throw to implement Rust-like propagation (sort of) I wrote on a whim since I noticed in your README you asked for alternatives to try?, I doubt I'll be able to spend any more free time on it, but it's possible I'll be able to dedicate work hours at some point to make it "production ready" if this approach seems like something you'd want to adopt.

Even with this change, I don't think the Result type actually works like Rust's ? in this case because in Rust (though I'm not an expert), the Err type would be a union of all the possible errors, where this current implementation doesn't support that and I expect you'd have to use Sorbet's undocumented introspection methods to get a valid and comprehensive Err type, especially since there's no runtime checking of generics (which is probably why this test passes?).

assert_equal(R.err("err1"), result)
end

it "propagates the last try! ok" do
Copy link
Contributor Author

Choose a reason for hiding this comment

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

FYI, this test is deceiving. I think it's possible to do better, but this code actually just returns the result of the block, whatever that is. Since there's no runtime type checking of generics, I think you could just return an arbitrary Result from the block and there wouldn't be any type errors.

def self.propagate!(&blk)
# TODO: using singleton class instance variables is obviously bad and not
# thread-safe but demonstrates how it might work.
@ball = T.let(Object.new, Object)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Though most examples online of Kernel.catch use symbols, it actually uses object_id to catch a thrown object. The block you pass to catch gets passed a unique object that is specifically intended to be thrown for this purpose, but it seemed imprudent to expose that object to the block that the consumer's of this library see, so instead this just creates a new Object so that it will only catch this specific object's object_id and consumer code can't accidentally early-return if they use throw inside the block for some reason.

@jacobthemyth
Copy link
Contributor Author

Another idea I think would be possible using the R.propagate! {} approach is to emulate the From trait in Rust. When you use the ? operator, it will automatically convert the err type using From, and I bet there's a way to do that as well by scoping the unwrapping inside a block this library controls.

@jacobthemyth
Copy link
Contributor Author

A coworker pointed me to this article about why exceptions and yield might be preferable: https://mikey.bike/j/2023/04/dry-rb-monad-laws.html

@olivierbellone
Copy link
Owner

Very cool stuff, thanks! I will take a close look and experiment whenever I can find some time.

@olivierbellone
Copy link
Owner

This is interesting! I actually had a less sophisticated version of this, with a TryFailed exception class meant to be caught by the R.propagate! class method. I ended up scrapping it because I had trouble preserving the types when wrapping the error value in the exception instance.

Your version works better, and I like that it is context-aware: when called outside of a R.propagate! block, #try! is a no-op (in my version, calling #try! on an R::Err instance would always raise the TryFailed exception which wasn't meant to be visible to library consumers).

Using throw and catch is very neat. I've been writing Ruby for a while and I vaguely knew about these two keywords but I'm pretty sure I've never actually used them 😄 Using a unique Object instance is also a clever way of ensuring the library will only ever catch the balls it threw itself (great variable name btw).

Having to wrap everything in an R.propagate! block is still annoying... but I don't think there's a way around it. Rust can do early returns from a "function" because of its powerful macro system, but in Ruby I think we are limited to raise/throw (the approach in this PR) and calling return from a block (the approach used with the #try? method documented in the README).

Another idea I think would be possible using the R.propagate! {} approach is to emulate the From trait in Rust. When you use the ? operator, it will automatically convert the err type using From, and I bet there's a way to do that as well by scoping the unwrapping inside a block this library controls.

👍 Yes, I've also thought the same and tried reading the Rust source for From and the Try trait... I'm an absolute Rust beginner so I didn't get very far. I might take another stab at it sometimes. If you feel like giving it a go yourself, I'd love to see what you come up with.

Thanks again for the contribution, and sorry it took me so long to reply! I must have missed the notification.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants