This question has weighed on my mind for six years, apparently. ![]()
It's a tough one, but I don't think there's that much knowledge and technique coming down the pipe anymore.
Let's look at some usages in practice.
A Usage From Encapping
first-section-by-phy-offset: any [
sections.1
catch [
for-each 'sec sections [
if not zero? sec.physical-offset [
throw sec
]
]
]
]
Here you see CATCH serving a fairly typical purpose. It keeps you from having to write something more awkward like:
if not first-section-by-phy-offset: sections.1 [
for-each 'sec sections [
if not zero? sec.physical-offset [
first-section-by-phy-offset: sec
break
]
]
]
That makes you write out first-section-by-phy-offset: twice. With CATCH you're able to create a spot for that assignment and name assigning it implicitly with the throw.
Here we're taking advantage of the NULL default result for no throws. If we didn't have that, you'd have to say:
first-section-by-phy-offset: any [
sections.1
catch [
for-each 'sec sections [
if not zero? sec.physical-offset [
throw sec
]
]
null
]
]
That's definitely uglier.
I actually think I'd prefer a rule that said "all CATCH requires you to throw, or you get an error telling you that you forgot to"... kind of like RETURN and FUNC right now:
first-section-by-phy-offset: any [
sections.1
catch [
for-each 'sec sections [
if not zero? sec.physical-offset [
throw sec
]
]
throw null
]
]
The subtle "good" part about this is that it prevents people from ever thinking that a THEN or ELSE on the outside answers the question "was there a throw" The non-distorting nature of the THROW becomes self-evident.
It may not seem like a great feature to say that you get a panic when no throws happen. But it's not useless. And ATTEMPT is now plenty powerful for dropping out results...when you don't care about the difference between heavy null and null or heavy void and void. CONTINUE takes an argument, making it almost identical to CATCH. So the "must THROW" feature kind of distinguishes it.
I'm leaning this way, but let's look at some more data.
Console API Code Usage
if (Term_IO) {
return rebDelegate("catch [",
"throw as blob! cond (",
"read-line stdin except (e -> [throw fail e])",
")",
"]");
So this is interesting. This is actually a case that would be a fitting use of TRAP... prehaps with a dialected component to say "use THROW, not RETURN":
if (Term_IO) {
return rebDelegate("catch [",
"throw as blob! cond (trap/throw read-line stdin)"
"]");
But it's actually better than that, because libRebol API-based C natives function define definitional RETURN already. ![]()
if (Term_IO) {
return rebDelegate("as blob! cond (trap read-line stdin)");
That's wild. So this case can be simplified pretty well.
One observation I'd make is that the throw in catch [throw ...] didn't seem superfluous to me. Had it been missing, I think what's going on becomes a little less clear:
return rebDelegate("catch [",
"as blob! cond (",
"read-line stdin except (e -> [throw fail e])",
")",
"]");
The throw helps parse it. It helps even on a small example, but you can certainly imagine it being more helpful in a long one:
catch [
case [
... pages of code ...
... pages of code ...
... pages of code ...
... pages of code ...
... pages of code ...
... pages of code ...
... pages of code ...
... pages of code ...
... pages of code ...
... pages of code ...
... pages of code ...
... pages of code ...
... pages of code ...
... pages of code ...
... pages of code ...
]
] ; hm? oh? I guess that CASE was the result, then?
This is actually a pretty good argument for avoiding RETURN fallout in general. If you've got an applicable operator that can teleport, there's a certain degree of solidity given by saying "any teleports will be pre-announced."
Inside The Module Machinery
ignore ^product: catch [ ; IGNORE stops FAILURE! results becoming a panic
set (extend mod 'quit) make-quit throw/ ; module's QUIT [1]
wrap* mod body ; add top-level declarations to module
body: bindable body ; MOD inherited body's binding, we rebind
eval inside mod body ; ignore body result, only QUIT returns value
throw ~ ; parallel behavior to if body had done (quit 0)
]
This shows a case where throwing NULL by default would not have been desired. Wanting NULL may not be as common as I imagine it to be.
I'm not sure what arity-0 throw should return. I would think probably void. That makes it align with CONTINUE.
Another Console Example:
This is some code out of an API call:
"sys.util/recover [", // pollutes stack trace [3]
"catch [", // definitional quit (customized THROW) [4]
"sys.contexts.user.quit: sys.util/make-quit:console throw/",
"result': lift eval code",
"] then (caught -> [", // QUIT wraps THROW to only throw integers
"result': caught", // INTEGER! due to :CONSOLE, out of band
"])",
"] then (error -> [",
"result': error", // non-lifted ERROR! out of band
"])"
This shows the particularly insidious problem with fallout. This makes it look like the THEN runs when there is a throw. But there's no isotope magic going on here... and if this wrapped THROW happened to get a NULL it would skip the THEN, just like reaching the end of the catch without a throw would.
What Tips The Scales: Requiring THROW Is Undoable
No matter what the semantics are, we know that being explicit will work.
There's not a conception of CATCH in which catch [... throw value] wouldn't work.
The only question would be "does it distort light nulls and voids to their heavy forms", and I've concluded no.
People can customize it. A naive implementation:
catch: specialize catch/ [body: append copy body ~[, throw ~null~]~]
The reason it's naive is that making copies may not be what you want--there could be relevant identity. So you'd really have to do some binding surgery on a containment.
But anyway... the tools for doing it "right" are closer at hand than they've ever been.