[HN Gopher] The obvious final step
___________________________________________________________________
The obvious final step
Author : codewiz
Score : 45 points
Date : 2023-05-25 15:07 UTC (2 days ago)
(HTM) web link (akrzemi1.wordpress.com)
(TXT) w3m dump (akrzemi1.wordpress.com)
| pavlov wrote:
| Personally I would call this "execute within context". The
| context could be a database transaction, or saving/restoring
| graphics state, or swapping out some global state to control
| instrumentation, or whatever.
|
| The destructor approach has one advantage for semantic clarity:
| it's guaranteed that your execute-within-context block really
| executes immediately and just once. Passing a lambda to a
| function means the function could call the lambda now, or later,
| or ten times, or maybe never. Still, I think it's a more readable
| approach for contexts that are not holding resources.
| marcosdumay wrote:
| > Personally I would call this "execute within context".
|
| Oh, a monad.
|
| Yeah, a monad solves it. But it's quite overkill.
| mrkeen wrote:
| It would only be overkill if you had to invent it yourself.
| But since there's an existing solution, use that.
|
| The article went from destructors to the with-pattern. Once
| you start thinking about exceptions, you might reach for try-
| with-resources. With Async exceptions you might look at
| bracket. If you want composability, go for ResourceT.
| marcosdumay wrote:
| Basically everything on the article is overkill. You just
| need to compose the XML contents declaratively... what is
| the most natural way to compose data.
|
| The original API goes out of its way to be dangerous and
| hard to use.
| amluto wrote:
| The database transaction example has another good reason to use a
| closure or callback: many databases can fail a transaction with
| an error saying it should be retried (due to a deadlock being
| detected). If the body is a closure or other callback, the
| wrapper can call it again.
| timmb wrote:
| Sounds like a cause for impossible to find bugs if the wrapper
| decides to reuse the callback in rare cases likely only
| existing in production. Might be better to let the end user
| handle this scenario in case they have side effects in their
| callback, like std::move.
| amluto wrote:
| This type of use case is why I wish more mainstream languages
| would have stricter concepts of purity and side effects.
|
| Conceptually, a function implementing the body of a database
| transaction should be idempotent. If a language had a
| function type that did not mutate global or closed-over
| variables and only affected the world by calling methods on
| its parameter, it would be difficult to mess up.
|
| Haskell can do this.
|
| Rust's Fn(&mut txn) -> Ret is kind of close, but it doesn't
| express that the function may not have mutable captures, and
| it does nothing about interior mutability or calling impure
| functions.
| jameshart wrote:
| This is literally one of the oldest problems in programming:
| returning the stack to its previous state before returning from a
| subroutine, or jumping back to the start of a loop.
|
| Specifically, the XML element example involves 'pushing' the
| context of a new element into the document - a context which MUST
| be 'popped' before continuing.
|
| This problem is literally why GOTO was considered harmful, why
| old school programmers insist that subroutines should have one
| return statement, why functional programmers are right to despise
| mutation, and why iterators are preferable to loops.
|
| So as a programmer, you shouldn't be surprised that your language
| of choice has clever ways to solve this problem, or that you can
| think of a dozen ways of avoiding this. It's like the ur-problem
| of writing code.
| weinzierl wrote:
| With an expressive enough type system you could also leverage the
| Typestate Pattern. This would have the further advantage of not
| only taking care of the final step but also the intermediate
| ones.
|
| For example in the first example from the article calling
| _xml.attribute_ doesn 't make sense without a prior
| _xml.begin_element_.
|
| By using the Typestate Pattern the compiler would ensure that you
| can only call _xml.attribute_ after a _xml.begin_element_. Of
| course all of this only works in cases where the order of steps
| is fixed at compile time.
|
| EDIT: Please read chrismorgan's comment why, at least in Rust,
| this does unfortunately not really solve the _" Obvious Final
| Step_" problem.
|
| https://news.ycombinator.com/item?id=36095908
| segfaltnh wrote:
| > Any use of a lambda, as well as any inverison[sic] of control,
| makes code more difficult to read or debug
|
| Anyone else immediately think about lambdas and surprised by this
| zinger at the end? I'll grant that in some languages lambdas are
| harder to debug, but I don't find the example more difficult to
| read or reason about _at all_.
| raldi wrote:
| Even if there's only one thing that could possibly go there, you
| shouldn't omit the final
| jrajav wrote:
| > you shouldn't omit the end.
|
| Sometimes there's a different way to express the same thing
| without losing any meaning, but which might be more terse or
| robust.
| VagueMag wrote:
| I make pretty heavy use of Java's AutoCloseable interface for
| this purpose. The lambda approach is an interesting one that
| could be applied in most languages though.
| dataflow wrote:
| I think they're looking for scope guards, in particular
| scope_success...
| https://en.cppreference.com/w/cpp/experimental/lib_extension...
|
| https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n41...
|
| https://www.youtube.com/watch?v=WjTrfoiB0MQ
| chrismorgan wrote:
| > _The problem with this is that it is easy for the programmer to
| forget to write the obvious final step. It wouldn't be a problem
| in a different, imaginary language or IDE where the machine would
| recognize a library with an obvious final step, and signal a
| compiler /IDE error when the programmer forgets it, or if it put
| the obvious final step into the code for the programmer. But this
| is fantasy._
|
| It's something you'd get with a linear type system (use values
| exactly once), but they're not present in any particularly
| popular languages. I suspect Haskell might be able to achieve
| roughly this, but I'm not certain.
|
| The closest I know definitely about is Rust, which has an affine
| type system (use values at most once). You can mark types and
| functions with the #[must_use] attribute, which says that you
| must do _something_ with values of the type or returned from the
| function at least once, or it'll produce a compiler warning. But
| what's needed in these cases is that you must _consume_ the
| value. I have often wished for this in Rust, but I doubt it would
| be accepted into the language (and so haven't tried proposing it
| formally) because of its comparatively niche usefulness due to
| the interactions with destructors and panic unwinding.
| (Basically, you couldn't actually rely on it for resource
| management: if you wanted it for something like "end database
| transaction, but actually do something with an error code rather
| than silently dropping it like a destructor", you'd still
| actually need to implement a regular no-return-value destructor,
| so is requiring that the user write `tx.commit()?;` actually
| useful, given that a panic before that line would lead to
| effectively `drop(tx);` happening instead?) For most purposes,
| #[must_use] is _good enough_. But it still disappoints me,
| because I think most places that #[must_use] gets used, they'd
| actually be better suited by a #[must_consume]. Hmm... maybe I
| should contemplate proposing it after all.
|
| But yeah, the lambda approach is often a good solution.
| weinzierl wrote:
| I was thinking about the Typestate Pattern because it would
| also take care of the order of the intermediate steps. Is it
| possible to mark the last type with #[must_use] and get a
| compile time error if I do not go through all steps?
|
| EDIT: I found this:
|
| _" Basically, the typestate pattern in Rust today provides a
| way to ensure that state machines are used correctly, but does
| not fully defend against them being unused, or discarded before
| completion."_ [1]
|
| So I guess #[must_use] is not enough and that's what you were
| saying in your comment already.
|
| EDIT 2: I found another good write-up of this topic:
|
| _" The Pain Of Real Linear Types in Rust_" [2]
|
| [1] https://users.rust-lang.org/t/write-up-on-using-
| typestates-i...
|
| [2] https://faultlore.com/blah/linear-rust
| chrismorgan wrote:
| You don't _need_ anything along the lines of typestate,
| though it can fit in fine as well: having all your functions
| consuming self and returning a new Self (except the consuming
| "destructor") is sufficient. It's just an ergonomics pain
| since you need to assign it to something every time:
| #[must_use] struct Transaction; impl
| Transaction { fn new() -> Self { ... } fn
| action(self, ...) -> Self { ... } fn commit(self)
| -> Result<(), ...> { ... } } let mut tx =
| Transaction::new(); tx = tx.action(...); // - `tx =`
| is the difference. // (Any panic at this point will
| roll back the transaction via Drop, // but any errors
| in the process will be swallowed or abort or who knows what,
| there's no standard.) tx.commit()?; // unused-must-use
| warning on the last assignment of tx (`tx = tx.action(...);`)
| if this line is absent
|
| A hypothetical #[must_consume] would let it be like this:
| #[must_consume] struct Transaction; impl
| Transaction { fn new() -> Self { ... } fn
| action(&mut self, ...) { ... } fn commit(self) ->
| Result<(), ...> { ...; drop(self); Ok(()) } }
| let mut tx = Transaction::new(); tx.action(...);
| // (Same panic behaviour.) tx.commit()?; // unused-
| must-consume warning on the last assignment of tx (`let mut
| tx = Transaction::new();`) if this line is absent
|
| With putting the state into the type, you'd be limited to
| annotating your entire generic type with #[must_use], not
| individual states. (#[must_use] won't propagate through
| generic parameters, nor should it.)
| weinzierl wrote:
| That makes it clearer. Thanks!
| oleganza wrote:
| Knowing that Ruby libraries used block (lambda) pattern for the
| past 25-ish years, it was very painful to read through the first
| 90% of the article explaining all the issues with abusing
| destructors.
|
| Maybe because first-class support for lambdas was added too late
| in C++ and Java, or maybe because it was always PITA to type.
|
| Ruby syntax is the lightest there could be (and it supports both
| {braces} and do/end keywords)
|
| transaction do foo() bar()
|
| end
| hiddew wrote:
| > Ruby syntax is the lightest there could be
|
| Kotlin can have exactly the same code with lambdas, using
| either a receiver type, or a context receiver. And it's type
| safe. transaction { foo()
| bar() }
|
| Type-safe DSLs become a pleasure to build and use.
| rco8786 wrote:
| Yup, came to say this same thing! I read this whole article
| going "this is easily solved in Ruby"
| mrkeen wrote:
| How does ruby prevent you from doing non-transactional things
| between 'do' and 'end'?
| slondr wrote:
| The same way you would be prevented from doing non-
| transactional things between calls to transaction.start() and
| transaction.end()
| mrkeen wrote:
| When I write this: someTransaction = do
| incrementCounter writeFile "foo.txt" "bar"
| decrementCounter
|
| I get this: src/Lib.hs:11:5: error:
| * Couldn't match type 'IO' with 'STM' Expected:
| STM () Actual: IO () * In a stmt of
| a 'do' block: writeFile "foo.txt" "bar" In the
| expression: do incrementCounter
| writeFile "foo.txt" "bar" decrementCounter
| In an equation for 'someTransaction':
| someTransaction = do incrementCounter
| writeFile "foo.txt" "bar"
| decrementCounter | 11 | writeFile
| "foo.txt" "bar" | ^^^^^^^^^^^^^^^^^^^^^^^^^
| atoav wrote:
| A pseudocode python variant could look like with
| xml.element("port") as e: e.attribute("name", name)
| e.attribute("location", location)
|
| And that's it
| pphysch wrote:
| Pretty much how https://github.com/Knio/dominate does it, but
| no context variable binding (otherwise gets messy with lots of
| nesting)
| mattgreenrocks wrote:
| After many years in Ruby, I feel strongly that the idea of a
| block is necessary for optimal expressiveness of a high-level
| language. Destructors are too implicit and fine for resources,
| but not as great when understanding code. And Python's context
| managers trying to help with exception-safety is a nice touch.
|
| The Lisp crowd was right, as usual.
| nerdponx wrote:
| A general concept here is of a "context", which both Ruby
| blocks and Python context managers try to provide. Something
| happens when you enter the context, your code runs, and then
| something happens when you exit the context, maybe varying
| depending on how you exited.
|
| Such "contexts" are somewhat ad hoc in Lisp, whereas Ruby and
| Python have built-in language support and special syntax for
| them. The traditional Lisp idea is that you don't need special
| language support when you have macros and higher-order
| functions, but the special language support also means that all
| context managers follow the same protocols, whereas ad hoc
| contexts introduced by CALL-WITH-FOO/WITH-FOO are more like a
| common loose convention than a standardized system.
|
| Functional programmers have also understood this idea of
| structured contexts for a very long time, giving us Functor,
| Applicative, and Monad.
| lispm wrote:
| One other way to deal with 'context' in Lisp is for example
| by the ADVICE mechanism or the method combinations (:around,
| :before, :after, ...) of CLOS.
|
| https://en.wikipedia.org/wiki/Advice_(programming)
|
| > all context managers follow the same protocols
|
| Lisp is an old language with lots of dialects and systems.
| Thus people had different needs for context management and
| different facilities available to implement it.
| marcosdumay wrote:
| Lisp? You don't need to go all the way to Lisp.
|
| Declaring data imperatively is quite a contradiction. You need
| a lot of effort and misguided design to create it. You don't
| need first class code blocks to avoid it.
|
| (But, of course, first class code blocks help in many other
| ways.)
| baq wrote:
| > The Lisp crowd was right, as usual.
|
| 40 years ago.
|
| Fortunately we're getting there, feature by feature. Maybe 2030
| will be the decade yaml goes away.
| barefeg wrote:
| What would be the way to do yaml?
| adamfeldman wrote:
| Code as data https://news.ycombinator.com/item?id=16398791
| akavi wrote:
| What do blocks give you that good old-fashioned first-class
| functions don't?
| kstrauser wrote:
| For example, here's how you'd read a file in Python and
| operate on its contents. with open('file') as
| myfile: process(myfile.read())
|
| When control leaves that context, `file.close()` is always
| called. You don't have to remember to close it. Even if
| `process()` raises an exception, the `with open` context will
| close it before passing the exception upward. You never have
| to remember to clean up the file yourself.
|
| It's not that you can't do all that without context managers,
| obviously. People have done it for a long time. It's just
| that the `with open...` pattern is effectively bulletproof
| and removes just one more potential footgun.
| akavi wrote:
| In JS, we could do: open('file', (myfile)
| => process(myfile.read()))
|
| It would be on the implementer of `open` to call
| `file.close()`, but presumably that's true of python as
| well?
| norir wrote:
| I agree. The most important feature of a high level programming
| language is first class closures with lexical scoping. Almost
| every design pattern, including the IOC in the original
| article, can be implemented with them (along with a few other
| core data structures and primitive operators). By contrast,
| there are many patterns that are very difficult to implement by
| hand in c because of the lack of first class closure support
| (c++ of course is a bit better since it has lambdas, but then
| you have to opt in to everything else in c++).
| rco8786 wrote:
| Yup, came to say this same thing! I read this whole article
| going "this is easily solved in Ruby/Lisp"
| whstl wrote:
| Anecdotal, of course, but: often the most elegant solutions I
| see in my job are from programmers coming from Ruby. It is
| interesting to see how different programming languages can
| shape our mindset.
|
| A big part is those good solutions are those little lambda-
| receiving functions that do all the heavy lifting without
| imposing a lot of boilerplate. Not too many of them, of course.
|
| IMO this kind of thing is where the "getting things done" and
| "good architecture" meet.
| classified wrote:
| The "Inversion of Control" section shows the solution. Using
| destructors is wrong on so many levels.
| nerdponx wrote:
| As far as I understand, Kotlin doesn't actually have first
| class blocks like Ruby, despite the appearance that it does.
| Instead, blocks are syntactic sugar for anonymous functions, so
| y = foo() { bar(it) }
|
| is just pretty syntax for y = foo({ it ->
| bar(it) })
| oh_sigh wrote:
| What's the difference?
| nerdponx wrote:
| Functionally, not that much. But in Ruby it requires a
| special language construct, whereas in Kotlin it's just
| pretty syntax for something else that's already in the
| language.
___________________________________________________________________
(page generated 2023-05-27 23:02 UTC)