Blog

Impressions on the Keychron Q6

I’ve had a soft spot for mechanical keyboards for a long time. It’s a cliché, I know. I’m not a fan of loud mechanical keyboards, mind you. I’ve had my hands on Cherry MX Blues and found them to be so loud as to be a distraction during calls. And I found the Cherry MX Reds to be, well, too quiet. I found the Goldilocks zone to be in the Gateron MX Browns.

I have also come to particularly like the Keychron keyboards. They have a bit of a shaky reputation online, but I’ve never had any problems with my Keychrons and I adore them. This Keychron K8 with Gateron Brows keys has been my main keyboard for the last 2-3 years.

Picture of a Keychron K8 keyboard

It is an awesome keyboard. I like how it feels, I like how it sounds, and I like how it clicks. It’s great.

The only thing my K8 lacked was a numpad, and since I have a certain passion for flight simulation, a numpad is something useful. I could buy a USB numpad. But where’s the fun in that? So this was the beginning of my search for a new keyboard.

After some online conversations, I settled for another Keychron favourite, the K4.

A picture of a Keychron K4 keyboard

And this keyboard feels amazing. I cannot stress this enough: it’s the most pleasurable clicking experience of my life. In theory, it has the same Gateron G Pro Browns as my K8, but for some reason, it feels better. I’m not sure how else to put this: they feel less metallic-y. I don’t know how else to put it.

Great size, great feel, great quality. I fell in love with this keyboard the moment I unboxed it. Except it has a terrible flaw that might be evident from the picture, but that I never noticed until I started using it. Do you see it? It’s right there by the arrow keys. Do you see it now?

There’s no spacing. The arrows are right under the Enter key but there’s no padding space between them and, say, the 0 key on the numpad. Years of muscle memory down the drain. I could not reliably hit the arrows without looking. I tried. The layout of the Del, End, PgUp, PgDown keys was also a bit foreign to me, although I was able to adapt relatively quickly to them. The arrows keys though, I simply could not. I replaced them with some texturized rubber keys but I was still unable to hit them reliably. It was a pity.

Enter the mighty QMK Q6.

A picture of a Keychron Q6 keyboard

This keyboard is a beast! It weights 2.5Kg, which is insane. But OMG it feels so, so good. If I had to choose, I would still pick the feel of the K4, but it’s close. The Q6 has better keycaps though.

In terms of sound, it sounds very close to the K8, maybe a tad softer but it’s hard to tell. In terms of feel, it’s between the K4 and K8. It feels closer to the K8 than to the K4, but yeah, somewhat in the middle between the two.

I love it. I credit it for the fact that I am writing this right now: I just feel like typing! I really like this. As an additional bonus, its firmware’s source code is open source. I don’t plan to use it for anything, but it’s good to know.

However, it is not perfect. Don’t get me wrong: I’m very happy with this keyboard but it does have one extremely questionable design decision that I honestly can’t understand: it doesn’t have feet. It just lies flat on the desk which feels quite awkward to me. I cannot understand the decision to do that. I solved it with some cheap adhesive rubber feet, so all is good, but why Keychron decided to ship it like this is beyond. So be aware of this.

Other than that, I absolutely love it.

In which I reminisce about the last few years

I just checked and it’s been exactly 1,594 days since I last posted on this blog. That’s 4 years, 4 months, and 12 days. This was, as is often the case with these, not planned. When I last wrote something here, I was working in a team set up as an R&D lab. Work felt quite fun and exciting and writing about it felt natural.

I then changed to jobs to a startup where things felt a tad different. It was a weird time for me: I met some great people there, people I still talk to and call friends. We put together a small team where I got to do some of the most fun work. Some of the people in that team I still talk to every single day. We’re still trying to put the group back together in some form in another company. And yet, my time in that company, outside that small team, made me feel quite small and inadequate. Writing about it did not feel natural.

I then joined HashiCorp, a company I’ve admired for years. I won’t go as far as saying this was a dream of mine, but when I got the offer, it sort of felt like it. I’ve been here for about two and a half years now and I’ve met some extremely brilliant people, and a few that I can call friends. I should have written about it. I wish I had. But by this time, the writing habit was already gone and life does what life does.

What else happened over the last few? Well, we became Canadian citizens. That was a blast, even if pandemic-style remove ceremony was a bit awkward.

We bought a house and got a new dog, Loki. He’s an English Cocker Spaniel, as would be expected of us, as he’s our 5th —

All in all, I can’t complain. On the other hand, I am getting older, which sucks, let me tell you that.

Anyway, I’d like to get back to writing a bit, I used to enjoy it quite a bit. We’ll see. Hopefully it won’t be another four years until the next post.

The Last of Us Part II

I’ve finished the game last night. I haven’t stopped thinking about it ever since. It was, to be very honest, a transformative experience, as far as videogames go. I understand why some people hate it and I’m sorry because I understand how much it sucks when you want to enjoy something but can’t. Art is subjective and no one is right or wrong.

That said, I want to talk about what I’ve experienced. Again, this is my experience with the game. I’m sure yours will be different and it is fine. If you hate this game, you’re not wrong. You feel what you feel.

With that out of the way, let me begin with the least controversial theme: the gameplay.

Gameplay

I thought the gameplay was an improvement on the first game. I don’t mean only the added functionality like ropes, and dogs tracking you. But the mission structures were more varied and some of them were, well, epic.

The sky bridge was awesome. So was trying to get near the sniper (Tommy) by advancing behind cover. The stalkers were a fun addition which gave me a lot of jump scares. That huge new infected monster at ground zero was… argh! Kill it now! The entire time spent on the seraphite island was amazing.

I also loved little things like figuring out the code for the safes.

The Story

This is what is dividing people, and it’s fine. Art that everybody agrees on is boring. The way I see the story, it is one of mirrored character journeys (as much of popular culture is.) Through three characters, we see the same journey at different points. I think this is genius. Here’s how I figure.

Joel

First all all, yes, it sucked to see him killed. That’s the point: we are supposed to be angry that he’s dead, that he’s killed in such a way. This is very much intended: it puts us in the place of Ellie. It is perfectly expected that you’d feel rage towards his killers. I think it becomes a problem when that rage is instead directed towards the game itself, a work of fiction, because then it makes it really hard to appreciate the rest of it.

Let’s talk about his character journey, shall we? In the first part, we meet Joel at the onset of the pandemic. We don’t know much about him before then but all signs point to an average joe and a good dad. Then his daughter gets murdered and we skip 20 years, by which time we are to understand that Joel is not a good man anymore. We learn that he was a killer, a robber, smuggler. He says he killed innocent people, he robbed (and presumably killed) good samaritans. He absolutely did not care for Ellie in the least at first. He wanted to abandon her to the soldiers and run when they caught up to them in Boston. It was Tess who made him stop.

In the first game, Joel was a “bad person,” a broken man who then had his redemption by protecting (and ultimately caring for) Ellie. (More on the “bad person” in quotes later.)

Abby

Abby’s journey is the same as Joel’s. Her dad gets murdered by a smuggler and that breaks her. She becomes a bad person. We’re made to understand that prior to the events in Seattle, she was a cold-hearted killer. She rose though the ranks of the WLF by becoming the “top scar killer” and even her friends think she’s, to steal Mel’s description, piece of sh*t. She’s a murderer.

And then comes Lev. At first, she too doesn’t care too much. He is just a scar. You see how Abby dehumanizes the seraphites all the time, the difficulty she has with ever calling them anything other than “scars.” She leaves them there but then she feels guilty and, reluctantly at first, goes back to them.

Abby was a “bad person” who had her redemption by protecting and caring for Lev. In a way, Abby’s journey is what we would have seen if we were able to follow Joel through those 20 skipped years.

Ellie

This is where things get interesting. Ellie is living in Jackson the life that Joel and Abby lived before their own traumatic events. When Joel dies, this matches the deaths of Joel’s daughter and Abby’s father. This is where Ellie is going to turn bad.

And we see that transformation unfold. We see our Ellie slowly go down the same path that Abby and Joel took years before and that we never got to see. She is consumed by rage.

Ellie is now becoming a “bad person” as well. But she’s not all the way there yet.

She’s also consumed by remorse. When she tortures Nora, she comes back to Dina visibly shaking and saying “I made her talk…” She’s devastated about what she’s done. Later she kills Owen and pregnant Mel, and again, that breaks her. And yet, she cannot stop.

Ellie vs Abby: why didn’t Ellie kill her?

The final confrontation is amazing. Abby is now in a different place, she’s now where Joel was after Ellie. It’s hard for us to forgive her, because we have seen her kill Joel, but nevertheless, that’s the place she is in. When Ellie cuts her down from that beach post, her first reaction is to run and cut Lev down. Lev is her Ellie. She doesn’t want to fight Ellie, she only wants to save Lev, just like Joel only cared about saving Ellie at the end.

But Ellie still can’t let go. She needs this! Or so she thinks. And so they fight. The fight fell heavy and, to me, very real. It was amazing and painful to watch and even worse to participate in. I did not want to fight Abby. All I wanted was for those two women to find peace now.

And then Ellie is about to kill her and she remembers Joel. More specifically, she remembers her very last conversation with Joel, about her inability to forgive but willingness to try. She also sees the changed man, who changed for and because of her, the man who went from “bad” to a “good” person. Killing Abby — who’s now on her own redemption path — will only turn Ellie into the bad person that Abby and Joel were.

And Ellie stops the cycle. She will not go down the same road. She honours Joel by refusing to become a bad person, something Joel would never have wanted for her. She will honour Joel by going back to the life both of them wanted for her.

Will she get it? Will JJ and Dina be waiting back in Jackson? We may never know, but I sure hope so.

“Bad people”

I think the biggest takeaway for me in this painful, yet wonderful journey of a game is how none of the protagonists were good or bad. Everybody is the hero of their own story. Since we played the first game as Joel with Ellie, that is our story and we are entirely on their side. But they were not “good,” not when it comes to the many lives they took over the course of their journey. We saw all those kills as completely justified: they were goons, they were going to shoot us! But then, from their perspective, they were doing the same thing we were. And the thing is, that doesn’t mean everyone is equally justified. It only means that from their own points of view, Ellie and Joel were the villains.

If doesn’t matter if we believe Joel was justified in taking Ellie from the hospital: from the point of view of Abby, her loving dad was murdered. It’s also not about convincing you and me that there are not moral absolutes. It’s that none of this matters to the characters themselves.

Conclusions

Again, I’ve finished this game almost 24 hours ago and I am still thinking about it. This game made me feel so many feelings. It made me mad, sad, and happy. Art that gets you to feel something is, well, good art in my book.

If you hated this game, it’s fine. We can’t all love the same thing. I am not trying to convince anyone, just sharing what I felt. I absolutely loved it with all my heart. This game will stay with me for a long time.

Try was worth trying

The Go proposal committee has declined the try proposal.

I am disappointed. I also think this is open source working exactly as it should.

The proposal was not dropped because one person with commit access disliked it. A concrete design was published, tools were written to try it on existing code, hundreds of examples and objections were discussed, and the people behind it kept answering questions long after I would have quietly disconnected my router and moved to the woods.

Most importantly, the Proposal Review Committee made its decision in response to that discussion. The closing note mentions problems the original design had missed, including debugging prints and code coverage. It also admits something more fundamental: many Go programmers do not agree that the verbosity of error forwarding is a problem worth changing the language to solve.

That is a good reason to stop. A language belongs to the people who have to read it, and a change to control flow needs more than a clever specification. The community worked through the tradeoffs together and the committee made the call. I may disagree with the result, but I trust the process far more after seeing it work.

Still, I think this will eventually look like a missed opportunity.

The argument against try was strongest when it was used inside nested expressions:

return write(encode(try(read(name))))

I do not want to debug that either. But bad nesting was not the only possible future. The ordinary form was much less alarming:

b := try(ioutil.ReadFile(name))
try(json.Unmarshal(b, &cfg))

One operation per line, errors still in the function signature, no exceptions, and an explicit if whenever local handling mattered. I think that style would have become normal very quickly. It looks unusual today mostly because it is new.

There is a difficult bias in evaluating syntax: familiar boilerplate does not feel like syntax anymore. We see if err != nil and read “return the error.” We see try and inspect every unusual corner because it is new. That scrutiny is necessary before changing a language, but it is not a neutral comparison.

Rust went through a remarkably similar argument around try! and later ?. People worried about hidden control flow and two ways to write the same thing. Once programmers had lived with it, many came to consider it one of the language’s better features. Go is not Rust and should not become Rust, but people do learn new control-flow notation. We already did it with defer and go.

There is another group absent from a discussion among current Go programmers: people who did not choose Go because they found its error handling tedious. It is almost impossible to measure them from inside the community. The people most comfortable with the status quo are, unsurprisingly, the people who stayed.

None of that means the committee should have ignored the reaction and forced try through. That would have damaged the language and the community more than a little error boilerplate ever could. Declining it was the right decision now, given the lack of agreement about both the problem and the solution.

But I suspect we solved the social problem by leaving the technical one untouched. Years from now we may add something remarkably similar under another name, after enough other languages have made the idea feel ordinary. Or we may simply keep writing the same four lines and insist they are valuable because we have become very fast at not seeing them.

Either way, try was worth trying. The proposal improved our understanding of the problem, and its rejection proved that the Go community can say no even after a great deal of work has been invested.

That is not failure. I just hope it is not the last word on improving error handling.

The argument over try

It has been six days since the try proposal was posted and the issue already has hundreds of comments.

Robert Griesemer has posted a useful summary of the discussion. After reading far too much of the thread, I think the arguments are clearer now. They are also better than “I hate typing if err != nil” versus “real programmers enjoy typing it,” which is where these discussions usually go to die.

The strongest case for try is ordinary sequential code:

func readConfig(name string) (*Config, error) {
	b := try(ioutil.ReadFile(name))
	var cfg Config
	try(json.Unmarshal(b, &cfg))
	return &cfg, nil
}

There is very little error handling in the expanded version. Both errors are simply returned. The extra if statements interrupt the description of what the function does without adding a decision.

This is not merely speculation. Some people have tried converting code from the standard library. One conclusion was that try made most of those examples easier to read. Another person looked at much the same evidence and reached almost exactly the opposite conclusion.

The proposal has other good properties. It does not introduce exceptions. Errors remain ordinary return values. Existing code keeps working. try handles the dull case while an if remains available whenever the error needs local attention. I find that separation appealing.

The invisible return

The best argument against it is simple: try looks like a function call but changes the control flow of its caller.

Consider this:

req.Header = Header(try(tp.ReadMIMEHeader()))

That assignment can return from the enclosing function. A struct literal containing try can return. A nested expression can contain several possible returns on the same source line. Today my eyes know that constructing a value does not quietly leave the function. With try, they would have to search inside every expression.

Some commenters therefore prefer a keyword or statement form:

try f := os.Open(name)

It is harder to miss, but it is also a larger language change and it gives up the proposal’s useful ability to fit where an expression fits. Restricting try may make it safer while removing much of what makes it attractive. Language design is fun like that.

There are practical problems too. If I add a debugging print to one particular failure today, there is an obvious block in which to put it. If several operations use try and one deferred handler wraps all of their errors, identifying the exact operation may require changing the code back to an if.

Coverage has a similar problem. The explicit error block is visible to coverage tools. A hidden return inside try creates a branch that source-level coverage needs to represent somehow. Debuggers and stack traces also need enough information to distinguish multiple try calls on one line. These are practical objections, not just a matter of taste.

The context problem

I am less convinced by the proposed use of defer for error context than I am by try itself.

A deferred handler can add function-level context once:

defer fmt.HandleErrorf(&err, "reading configuration %s", name)

That is lovely when every failure in the function deserves the same context. It is less useful when opening the file, parsing it, and validating it each need different information. The handler also runs on every return and encourages named error results. None of this is fatal, but it means the proposal is strongest exactly where error forwarding is least interesting.

So I still like try, with qualifications large enough to require their own parking space. I would avoid nesting it and use one operation per line. I would keep if err != nil wherever an error needs local context, cleanup, logging, or translation. In that style the hidden control flow is bounded and the useful code becomes much easier to see.

But Go cannot add a feature on the assumption that everybody will use my preferred style. If nested try is legal, nested try will exist. If a return can hide in a composite literal, people will write code that does exactly that.

I thought the original proposal was likely to fail because it felt unlike Go. The discussion has produced better reasons than feelings. I still think we may be rejecting an idea that would become ordinary and useful with time, but it is no longer difficult to understand why so many people are wary of it.