43 comments

[ 2.7 ms ] story [ 108 ms ] thread
It is about time for a good old American revolution.

Most of the White House should be executed on the front lawn.

Milley should be drowned into the Potomac.

Blinken should be thrown out of a helicopter.

The original poster says that these functions can't ever have invariants broken:

> Functions that only mutate instances of well-encapsulated types through their public APIs. That includes nearly all functions that are not methods.

> Methods of well-encapsulated types that only mutate non-member instances of other well-encapsulated types through their public APIs.

This is incorrect! Consider the following function (in psuedocode):

    func insert(id: Int, object: ThingInSpace) {
      ids.update(id);
      var location = get_location(object);
      locations[id] = location;
      var metadata = compute_metadata(object);
      metadatas[id] = metadata;
    }
If compute_metadata or get_location throw, then even though we're only modifying dictionaries or sets through their public APIs, we can still invalidate an invariant that affects our program correctness. This function is safe if and only if get_location and compute_metadata cannot throw. If they can throw, this should be rewritten as

    func insert(id: Int, object: ThingInSpace) {
      var location = get_location(object);
      var metadata = compute_metadata(object);
      ids.update(id);
      locations[id] = location;
      metadatas[id] = metadata;
    }
Adding "try" to the original function makes it clear that this function can destroy the invariant of ids, locations, and metadatas, and it needs to be rearranged to be the second function in order for the code to be correct.
But you broke encapsulation because locations and metadatas have an invariant that must be externally enforced. I don't think that meets the standard of well-encapsulated. If locations and metadatas must be updated together then you should only be able to do it via some method update() that takes location and metadata.
Consider this to be the only function that modifies locations and metadatas. It is the function that is supposed to maintain that invariant. You can pass in location and metadata (or make this a method on an object with those values as private members) without changing my point.

Under the rules laid down by the original poster in the link, functions which only modify well-encapsulated types via their public APIs cannot violate invariants.

My code shows that there are "invariant[s] that must be externally enforced" even in code that only deals with well-encapsulated types via their public APIs. And if you don't have explicit error handling, it isn't obvious that the invariant can be broken.

> well-encapsulated types via their public APIs cannot violate invariants

… of those well-encapsulated types. Throwing an exception like this cannot break an internal invariant of the list object which is what the author is saying.

You can’t break the list object which sounds crazy by modern standards but in languages without this kind of encapsulation (like C) this can and does happen.

That's a very useful guarantee, I agree, but it's not a good enough reason to not have "try" as a syntax.
You’re maybe right, but does that mean the language should be hostile to those that don’t write well designed code?
I think a lot of programmers would say "yes" to this, enthusiastically.
Yes. In particular you would want badly designed code to stand out.
Your premise assumes that `insert` will abort immediately if `get_location` or `compute_metadata` throw, but that's an arbitrary (poor) decision by the author of `insert`. The errors can (should) be caught and the relationship between `locations` and `metadatas` restored in the handler.

And there's certainly no reason for `insert` to re-throw its helpers' internal errors. What would its caller do with them?

I agree. A function that properly handles its helpers internal errors is better than a function that simply rethrows them. However, both are better than a function that simply rethrows them AND leaves the application in an inconsistent state. Additionally, adding the "try" syntax makes it clear that "insert" has been written poorly. For example, consider the code:

    func insert(id: Int, object: ThingInSpace) {
      var location = try get_location(object);
      ids.update(id);
      locations[id] = location;
      metadatas[id] = compute_metadata(object);
    }
Now I can be sure that compute_metadata doesn't fail, so it's fine that I've inlined the call, whereas it's important that get_location isn't inlined. But this syntax also suggests to a code reviewer that maybe insert should wrap the error returned by get_location and caught.

Of course you could always catch the error and restore the state, but without the `try` syntax, you can't tell by reading the function which lines can fail, and where you need to handle the error.

BTW thank you for reminding what a pain mutable data are, and doubly so, mutable data that can be put into invalid by merely calling legitimate methods.
(comment deleted)
Your example doesn't fit into either of those two categories. In order to have an invariant, `ids`, `locations`, and `metadatas` would be need to be members of a well-encapsulated type, one which has `insert` as a method. So the first category is out since `insert` mutates the instance's members directly, without going through the instance's public API, and the second category doesn't apply since `insert` mutates its member instances, and not only non-member instances. I believe the author would agree that this function needs the "try" keyword to show that the invariants can be broken by an ill-placed throw.

The proposal is to keep the "try" keyword but allow functions to explicitly opt out so that "try" is not needed on every function call which can throw. The author suggested a "throws_anywhere" keyword after the argument list. My proposal would be to add "try blocks" which have the effect of allowing an exception to be rethrown anywhere inside the block (i.e. within a "try block" you can omit the normal "try" on a function call). The pattern:

  try {
    do_something();
    do_something_else();
  }
would be equivalent to:

  try do_something();
  try do_something_else();
So if you wanted the effect of "throws_anywhere" you just wrap the whole body of the function in one of these blocks. But you could also wrap just part of a function, for example just the part where the invariants are maintained, and require explicit handling in the parts where invariants are temporarily broken.
One of the interesting things (IMO) about programming language design is how the designer can choose to make some concepts harder or easier to express, and how those decisions shape the kind of programs that are easy to write with the language.

Here, Swift (it seems) makes an implicit suggestion that a developer keep their error handling tight and bounded. If you don't, the language softly penalizes you with the need to add keywords to be explicit about changed flow. Go does something similar in eschewing try / catch error handling for a single panic / recover system and very explicit error handling via return values (this was a very intentional design to address what the language creators perceived to be common failure modes in code they were maintaining at the time in other languages, see here [https://go.googlesource.com/proposal/+/master/design/go2draf...] for details). In Go, it's harder to make a certain category of mistake, but at the cost of the user writing more code to handle the error path (and assuming the implicit cost that every line of code could introduce a different mistake).

Every language feature in every language we use (static type declaration vs. implicit types with casting, variable predeclaration vs. implicit creation on first write or read, GOTO vs. function calls vs. try/catch vs. continuation passing, etc.) makes these tradeoffs.

I don't know why but for a moment I read this comment in horror imagining language developers implementing telemetry in the compilers and tooling themselves, then relying on them to "improve the experience" by changing the spec every six months.
Oh, they wouldn't have to do that.

... a scan of GitHub and feeding keywords and constructs into a couple hoppers would be all the ML you needed for that. ;)

(... I'm joking, but if you're a language designer, this is actually not a terrible idea for figuring out how people use your language "in the wild." What you do with that information will separate wisdom from knowledge).

Microsoft dotnet has opt-out telemetry. I'm not sure for what.
What do you think of Java's declared exceptions (or whatever they're called)? They always seemed like a good idea to me, but were ruined by people being lazy or not understanding how to properly design exceptions.
Personal opinion: I think they were a good idea but in practice they don't work great because they try to pretend exceptions work differently from how they really work.

They bump up against a reality of exceptions in a bad way: once you have a system for throwing exceptions, the set of types your code must handle becomes fundamentally unbounded because any of your dependencies can change at any time. If there are no exceptions and you have a function-call control flow, you can enforce that the only types the caller understands from the callee are the types in the signature. If your language supports thrown exceptions, then at the site of a function call, the value resulting from the call can be the values in the signature or any exception that can be generated by any function called by the callee (and in general, without control of the entire code stack, that set is unknowable).

That's a fundamental truth of thrown exceptions, and trying to constrain it by putting the thrown type in the signature left us in the state we're in now... People just throw and catch very abstract exceptions because they'll have to handle those exceptions anyway, since it's impossible to guarantee a dependency won't try to throw them.

> "If there are no exceptions and you have a function-call control flow, you can enforce that the only types the caller understands from the callee are the types in the signature."

This has historically resulted in either ignoring errors from sub-functions, because their error return type don't map properly, or returning generic "i got an error" without any more information about what the error was.

And all this was historically patched over by having verbose log files that could be written to from anywhere and the user had to peruse and try to make sense of after the fact.

Some franework tried to create complicated error code (like Windows COM) but in the end there is no free lunch. Errors are hard.

With checked exceptions, if today my function can only throw a FileException, then tomorrow I cannot start throwing a URLException without breaking any callers depending on my function having only one possible exception type. The lead architect of C# cited this problem as one of his reasons for not adding checked exception to that language. https://www.artima.com/intv/handcuffs.html#part2

The solution to the problem with to have errors be dynamically typed (a trait in Rust, an interface in Go, etc.) and insist that callers be ready for an unknown error type to bubble up.

some disadvantages:

i) There's RuntimeException which is an unchecked exception which could happen anywhere, so even the absence of a throws is not a guarantee of not throwing.

ii) In order to avoid many different throws specifiers, you end up with wrapping exceptions to a smaller set of exceptions (e.g. JNDI may throw a NamingException that wraps an IOException)

iii) IDEs offer to put try-catch { // todo } to avoid compiler errors. Developers often stop thinking then, where the right thing is more often to let it throw - e.g. I've seen FileNotFound being caught and logged (at debug) and not exposed to a caller, so a UI doesn't see it, with bad results.

> iii) IDEs offer to put try-catch { // todo } to avoid compiler errors.

This is the thing that always pissed me off. The default catch block should be to wrap it in a RuntimeException and rethrow it.

Mods are downvoting and covering up the fact that Biden is a murderous piece of shit.

Multiple explosions in Kabul killing American citizens.

Are there any popular UI frameworks/architectures that are actually compatible with implicit context switching?

I see a lot of desire to purge await from languages but I find the explicit control necessary. What would UI code look like with implicit yields?

Kotlin is used to write UI code, and potentially-yielding methods are called with ordinary function call syntax. Kotlin does not have the "await" keyword, but it does have an analogue to the "async" keyword, called "suspend".

Here is a post from Kotlin's language designer on their philosophy: https://elizarov.medium.com/how-do-you-color-your-functions-...

Kotlin has context blocks instead of the looser context hopping continuation syntax of C# but it seems like it's trading one explicit syntax for another. You can still get off main thread runtime exceptions in Kotlin on Android, so it doesn't solve the issue entirely.

I think that just shows how necessary some kind of explicit syntax is, despite all the talk I see that it's unnecessary. I would love to see more examples though, or a Kotlin native UI.

C# uses “implicit context switch” on its async/await as it captures the current SynchronizationContext by default. On WPF/winforms/UWP and classic ASP there is one, so unless you other wise tell it, the await always comes back on the UI thread.

Console and ASP net core have null as their SynchronizationContext.

C# will not context switch until you yield execution with an await. There is some default continuation setup that will happen if you use the Task Parallel Library (where the context management is explicit but abstracted away inside the library) but that's a different thing.
> it captures the current SynchronizationContext by default.

Which results in the majority of async code, which doesn't run in a UI context, needing to spam `.ConfigureAwait(false)` everywhere. It's the most visible wart in C# today - and no end of requests for a more succint alternative: https://github.com/dotnet/csharplang/discussions/645

I mostly deal with UI code so of course I like it how it is but couldn't anyone create, say, a BackgroundTask<T> that extends Task where ConfigureAwait is defaulted to false?

Seems like a clear way to add it to the method signature and inform the caller and solve the issue with minimal work. Does that not work?

That wouldn't be appropriate: whether or not execution should resume on the same context is the caller's responsibility, not that of the function you're calling.

i.e. the same async method could be called from UI code and should resume on the UI thread immediately afterwards - or it could be called from library code - the method doesn't know that (nor does the person who wrote the method).

I see your point but in reality the callee did already make the decision by using Task/TPL. As a language C# leaves the continuation functionality to the specific awaitable implementation. It seems like the natural place to make a different choice.

I do agree that I do not want to see it, though.

It's important to distinguish:

  asset1 = await httpGET(...)
  asset2 = await httpGET(...)
from:

  assets = await Promise.join(httpGET(...), httpGET(...))
  asset1 = assets[0]
  asset2 = assets[1]
Where the first does one request and doesn't start the next until the previous had completed, and the second does them concurrently.

BUT - you could have a language where async functions are awaited by default, and you'd have to write something like:

  assets = Promise.join([pending httpGET(...), pending httpGET(...)])
Where pending means don't await, and absence of it when invoking an async function does require it.

Presence of an 'async' decorator on functions isn't strictly needed, but does convey extra information in the API, which is useful.

> Presence of an 'async' decorator on functions isn't strictly needed, but does convey extra information in the API, which is useful.

I disagree. It's akin to having a `gc` decorator on a function that uses GC. Maybe useful for C++, but not useful for high-level languages like Java, Swift, Go, JavaScript, Python.

Long-term, I firmly believe `async` will reveal itself to be a mistake. It's simply unwillingness by language designers (or more likely implementors) to treat threading like GC - "that magic is OK, but this magic needs to have extra syntax".

Go chose a different route and treats all runtime magic as implicit behaviour (well, almost all - goroutines can block in loops - but they're trying to fix that, or maybe they have already). Java (project Loom) is going down the same path. It's only a matter of time Swift, Python etc. realize their mistake.

I believe if you read far enough in the thread you’ll see Swift’s “Structured Concurrency” proposal does the same thing. In Go you must `go ...` to invoke concurrency. In Swift that would be `async let = ...` under the proposal (which has already been implemented) on main. `async` just says this function returns a “future” and `await` just desugars the future. I prefer Rust’s approach where the future is an actual type, but there’s a nice element to swift’s where it is prt of the syntax, as is similar with swift’s optionals.
Indeed, async let seems like a massive improvement! (Judging by this proposal [1].) Still the wrong default IMO (should be `let x = async ...` so that you can then do `let xs = [async f(), async g()]`) but definitely a step in the right direction.

Now just remove `async` and `throws` function annotations and/or make those default, and all noise will be removed!

[1] https://github.com/apple/swift-evolution/blob/main/proposals...

> unwillingness by language designers (or more likely implementors) to treat threading like GC

There is a technical reason behind this, it's not just style.

The async-await transformation is highly compatible with calling other things in the C environment and receiving callbacks from that environment.

In some implementations the async-await transformation converts nested async functions to stackless state machines that are compatible with the C environment assumed by other libraries (especially in other languages) and by the operating system.

But it does have some performance cost, compared with functions that can assume stack scope. That cost only appears when using functions labelled "async".

Making every function automatically async in this model is nicer syntax (imho) but adds that cost to every function, unless the compiler is able to do a more global analysis, or deviates from strong compatibility with the C environment.

Another approach is to transform them to system threads. Then you don't need the async-await transform and it's compatible with the C environment, but you lose performance in some types of program where async-await is used, and sometimes a lot of memory or address space. So there's still a cost. Especially on targets where threads aren't particularly well implemented.

Yet another is green threads or other C-compatible coroutine implementation. This costs some compatibility with the C environment on some targets, though. Switching stacks in C works almost everywhere, but not everywhere, and has portability issues. The standard library function to assist with stack switching has been removed from POSIX, but it was never particularly fast anyway.

Go gets around this by being willing to deviate more from the C environment. It goes hand in hand with the very self-contained nature of Go compiled programs. Java gets around this by being a JIT interpreter which is free to do a lot of things its own way inside the Java execution.

Or you can have a language where the implied `await` is delayed until the information is used, but the initiation starts as soon as the information required to initiate is ready:

  asset1 = httpGET(...)
  asset2 = httpGET(...)

  doSomethingWith(asset1)
  doSomethingWith(asset2)
No problem, both requests concurrent, don't have to think about it. They will probably be neatly batched by the executor into a single outgoing TCP packet too.

  asset1 = httpGET(...)
  asset2 = httpGET(functionOf(asset1))

  doSomethingWith(asset2)
Second request has to wait until the first has completed, because this is correct. It can't be started until the first is completed.
Linking to forum discussions is a bad substitute for a blog post presenting a view, and I can't determine why OP is linking this discussion from last year today. I wasn't able to take away any value from this link because I couldn't read all 153 comments in it.