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

Reply via email to