On Sun, Apr 27, 2025, at 20:06, Larry Garfield wrote:
> I'm going to respond to points raised by several people together; I'm using Ed's
> message as a starting point but this is also i response to Niels, Rob, and Andreas.
>
> On Sun, Apr 27, 2025, at 3:16 AM, Edmond Dantes wrote:
> > Good afternoon, Larry.
> >
> > Looking at the comparison table, it seems that the two most important
> > differences are:
> >
> > 1. Backtrace consumes a lot of resources.
>
> Yes. And even if it can be made faster (as it looks like Niels is doing, which is great), it
> will never be as fast as an empty constructor and a return. That's the level I'm
> proposing.
>
> > 2. There is an explicit contract for exceptions thrown by a function.
>
> Yes.
>
> > 3. I didn't fully understand the point about the exception hierarchy,
> > but it seems important too.
>
> I somewhat glossed over this point, but let me expand on it here.
>
> Exceptions currently have a Java-inspired hierarchy. Everything MUST extend either Error or
> Exception. The available exceptions are spread across core and SPL, but internals is not supposed
> to use the SPL ones.
>
> For the use cases I'm talking about, "InvalidArgumentException" is the most
> common case, I expect. Or rather, special cases of that. However, it extends LogicException, which
> is specified in the docs as "Exception that represents error in the program logic." Which
> is... *wrong*, because half the time or more an InvalidArgumentException is a case of validating
> user data that cannot be fully validated by the type system... and thus not really a programmer
> error. It's only a programmer error if they don't handle it gracefully.
>
> Moreover, all exceptions currently track:
>
> * message
> * code
> * file
> * line
> * trace
> * previous exception
>
> Those are useful when they show up in logs read by a human. For the use cases I am describing,
> none of them are relevant, useful, or desireable. These values are for programmatic use only. But
> all of those are baked into the exception system at its core. Just having a third "kind"
> of exception (in addition to extends Error
and extends Exception
) would
> not really solve anything.
>
> > The issue with the missing contract could have been solved even for
> > exceptions, without creating a new entity.
>
> This is incorrect, as making the current exception system checked would be a ginormous BC
> break. And having some throwables be checked and some not, but using the same syntax and class
> hierarchy... well, that's the reason everyone detests Java's exceptions. Let's not
> do that.
>
> > Regarding Backtrace, the following questions seem fair:
> >
> > 1. What if I want to know where the situation occurred? Can I just
> > ignore this information?
>
> That is completely and utterly irrelevant, just as it is completely and utterly irrelevant on
> which line a return
statement appeared.
>
> I think the framing of this concept as "lighter exceptions" is the wrong lens. I
> probably contributed to that in my initial explanation, so let me try and clarify:
>
> What I am proposing is not "Better exceptions." It's "a second kind of
> return value." It's closer to Rust's Result types or Go's multi-returns, but
> spelled differently. That is, it turns out (see the Error Model article previously), logically
> identical to checked exceptions. But if the mental model of "what is an exception" is
> what PHP has today, then that is more misleading than helpful. So let's not talk of that
> further.
>
> Rather, consider this example:
>
> class Repo {
> public function getUser(int $id): User {
> $record = $this->db->query("...")->fetchOne();
> if ($record) {
> return new User(...$record);
> }
> // Uh, now what?
> }
> }
>
> There's various ways to handle that case. Sticking an exception at the end of the method
> is one option, but as I've pointed out, a particularly bad one. "That user isn't
> here" is not an error case that should be able to silently crash the application, just because
> someone put an invalid number in a URL.
>
> We could make the return value ?User and then return null, but now we have to manually check
> for null every frickin' time we call the method, or we get random null errors in who knows
> where. And it doesn't let us differentiate between "user not found" and "that
> is a negative int, which is never correct you idiot."
>
> Suppose a hypothetical world where we had generics:
>
> // We literally need *nothing* on these classes other than their type.
> // More complex cases might, but in this case, anything more than this is a waste.
> interface RepoErr {}
> class NoSuchUser implements RepoErr {}
> class NegativeId implements RepoErr {}
>
> class Repo {
> public function getUser(int $id): Result<User, RepoErr> {
> if ($id <= 0) {
> return new Result::err(new NegativeId());
> }
> $record = $this->db->query("...")->fetchOne();
> if ($record) {
> return new Result::ok(new User(...$record));
> }
> return new Result::err(new NoSuchUser());
> }
> }
>
> And then to use it, you MUST do:
>
> function doStuff($id) {
> $ret = $repo->getUser($id);
> if ($user instanceof OK) {
> $user = $ret->value;
> } else {
> if ($ret->err instanceof NoSuchUser) {
> display_user_message('Who is that?');
> return;
> } else if ($ret->err instanceof NegativeId) {
> display_user_message('Buddy, that's not a thing.");
> return;
> }
> }
> // If you got here, it means the getUser call way way way up there was valid.
> }
>
> I think it's reasonably obvious that is extremely clumsy, which is why almost no one in
> PHP does it, including FP stans like me. Plus that whole generics thing.
>
> What I've started doing is using union returns as a sort of "naked result":
>
> class Repo {
> public function getUser(int $id): User|UserErr {
> if ($id <= 0) {
> return new new NegativeId();
> }
> $record = $this->db->query("...")->fetchOne();
> if ($record) {
> return new User(...$record);
> }
> return new NoSuchUser();
> }
> }
>
> function doStuff($id) {
> $user = $repo->getUser($id);
> if ($user instanceof UserErr) {
> ...
> }
> $user is now usable.
> }
>
> But that relies on me always remembering to do that check, and means static analysis tools
> can't know what the type of $user is until I check it. Better, still not great.
>
> What I am proposing is to take the Result type example and change the spelling to:
>
> class Repo {
> // We get reliable type information without generics!
> public function getUser(int $id): User raises UserErr {
> if ($id <= 0) {
> raise new NegativeId();
> }
> $record = $this->db->query("...")->fetchOne();
> if ($record) {
> return new User(...$record);
> }
> raise new NoSuchUser();
> }
> }
>
> And then something reasonable on the receiving side, which I've not fully thought through
> yet. The whole point of this thread is "should I take the time to think that through?"
> :-)
>
> If we used try-catch or something structured the same, then we at least get:
>
> function doStuff($id) {
> try {
> $user = $repo->getUser($id);
> // Code that uses $user, which is guaranteed to be a User.
> return ...;
> } catch (NoSuchUser) { // Note not capturing the value, as we don't need it in this
> case.
> display_user_message('Who is that?');
> } catch (NegativeId) {
> display_user_message('Buddy, that's not a thing.");
> }
> }
>
> I'm not convinced that's the right syntax, but it's a possible syntax. If you
> want to defer handling of a particular error to the caller, then you would explicitly do this:
>
> function doStuff($id): string raises UserErr {
> try {
> $user = $repo->getUser($id);
> // Code that uses $user, which is guaranteed to be a User.
> return ...;
> } catch (UserErr $e) {
> raise $e;
> }
>
> Which is why I think we do want some kind of syntax similar to Rust's ?, so the above
> could be shortened back to this:
>
> function doStuff($id): string raises UserErr {
> $user = $repo->getUser($id) reraise;
> // We have a good user.
> }
>
> If you try to raise an error from a function that doesn't specify it.... that is exactly
> the same as trying to return an array from that function. return array
would be a type
> error on the success channel. raise new ProductErr
would be a type error on the
> failure channel. Same idea.
>
> Again, I don't think try-catch in its current form is ideal. I'm not sure what is.
> I'm trying to decide if it would be a waste of my time to figure out what would be better. But
> again, this is *not an exception*.. This is a second return channel, aka a different way to spell a
> Result type, such that we don't need a clumsy Result type.
>
> --Larry Garfield
>
Hmmm,
Reminds me of working on wordpress's backend, where you would write something like
function get_user(): WP_User|WP_Error
-- or something like that (it's been a long time).
But if it was an exceptional error, you'd just throw. But, you'd have to write something
like this every time you called it:
if (($user = get_user()) instanceof WP_Error) { /* handle error */ }
// $user is WP_User
What you're suggesting is basically providing this via a separate "track" or
"channel" so it would look like:
function get_user(): WP_User raises WP_Error {}
$user = get_user() reraise;
I understand that these are mostly lightweight, programatical errors, not exceptions. So, for
example, running out of disk space, "not found" results, etc. These are things you should
just handle ... not report. However, there are cases where these do become exceptional further up
the stack. For example, if you are supposed to be storing a profile image and you cannot recover
from a full disk or a user that the client thinks exists -- you probably want to turn them into an
exception. Maybe something like this:
$user = get_user() reraise (WP_Error as LogicException);
where you can specify an error to be wrapped in an exception. The stack trace and everything would
come from this line, not where the error actually came from. That shouldn't be an issue though,
in most code.
— Rob