Hacker News new | past | comments | ask | show | jobs | submit login
Optional If Expressions (animaomnium.github.io)
56 points by todsacerdoti on April 15, 2023 | hide | past | favorite | 52 comments



This felt a little bit like I walked into an ongoing conversation, the fact it's the second post to a blog which appears to have begun already in mid-flow doesn't help with that, so, maybe I completely misunderstood what's going on.

> In a language like Rust, implicitly introducing Option is barely acceptable: Option is already a priveleged type

There's nothing special about Option, it's not a langitem for example, as AddAssign, Box and FnMut are. It's not a built-in type like f32 or [T; N], it's defined in core, but so are lots of things nobody would claim are "privileged".

It's a vocabulary type, but so is Ordering, so is ControlFlow, so is Duration, we need to agree what to call things, otherwise it's going to be a struggle to develop software.


> There's nothing special about Option, it's not a langitem for example

While Option is not a lang item, Some and None are. Because they are critical to the desugaring of `for` loops for instance.

Thus Option is, transitively, a lang item.


Good point, I'd forgotten Some and None are indeed needed to de-sugar for. Do you know of anywhere else the actual language guts need them ?


I can't think of an other one, but I won't claim exclusive knowledge of the language's desugaring.


The `?` operator works with Option. I know it is actually through the Try trait internally, but since it is only implemented for a few built-in type, it makes Option special.


Meh. That's just because it's an experimental / unstable trait.

That Rev or Peekable implement TrustedLen does not make them special.


Additionally, the compiler optimizes certain Option<T> types to not require additional storage space.


That optimization isn't specific to Option, it works for any enum.


Yeah, agreed. Option often appears in my typescript code in the form of possibly undefined variables. I unpack the Option by using a condition if I want to handle it or assert() if I just want the application to panic.

I love how TypeScript forces this. You need to narrow the “undefined” type away before you can use the object in any way. Ruling out undefined and narrowing the type is the equivalent of checking for Some or None.


It's not quite the same though. Since TypeScript uses union types, `T | undefined | undefined` is equivalent to `T | undefined` (and might even be `T`, depending on how that is defined!), while `Option<Option<T>>` is always a distinct type from `Option<T>`


Which is why TypeScript has T | null | undefined <taps temple>


That still doesn't let you represent `Option<Option<Option<T>>>`, requires you to remember the difference between null and undefined, and fails silently if you screw any of it up.


There’s no one right answer but this is how I pick, in many cases, when to use null vs undefined.


Agree (at least for non-personal projects, where I might be more inclined to use Option/Result types). Wherever possible, I use null as an explicit value when a reference is present but not currently assigned a value, and treat undefined as absence of a reference. I try not to even use the undefined keyword if possible (which is sometimes necessary in types, but I can’t remember the last time I used it in runtime code).


So.... (if (/= den 0.0) (/ num den))

Languages keep reinventing Lisp. Greenspun's 10th rule.


Would this not just return nil for the else case? This is already the behaviour of Ruby and Lua as well, of course both are unityped languages where "just return nil" semantics are the natural conclusion.

The OP however is discussing how this kind of thing would work in a language with a richer type system, like Rust.


I guess the Lisp example is an example of a nullable type, which he's railing against; he'd rather have prewrapped types like Some(result) vs None. But it's still an optional expression, and I'm not convinced that declaring an optional type, doing the expression(s), then unwrapping and checking the result for None, is any better than just using an exception handler. He insists that nullable types force you to check for null, but they don't, at least in his math example here. Just let the runtime do it and throw an exception.


> Just let the runtime do it and throw an exception.

Afaik there are no exceptions in rust only panics & `Result`s (and `Option` I suppose)


It will typically return nil, but it’s worth noting that in some lisps nil is the same as the empty list (). Not quite the same as Option/None, but conceptually more similar than most other languages’ null types.


Was Lisp really the first language where you could provide a custom if-expression or have a builtin if-expression?


As far as I know, Lisp was the first expression-oriented language, so the if-as-an-expression idea probably comes from there.

In contrast to Fortran, in which "if" was a strict statement, which doesn't evaluate to anything and cannot be used directly in assignment.


FORTRAN I and II then had only an Arithmetic IF statement with three labels to jump to, based on an arithmetic value ( https://en.wikipedia.org/wiki/Arithmetic_IF ).

FORTRAN example:

  IF ( N ) 10, 20, 30 
Above means:

  if N is less than zero, then jump to label 10
  if N is zero, then jump to label 20
  if N is greater than zero, then jump to label 30

LISP had a COND expression, with multiple condition clauses. An example with three clauses.

  (COND ((= x 1) 'one)
        ((= x 2) 'two)
        (T       'not-one-or-two))

  if X is 1, then return ONE
  if X is 2, then return TWO
  ELSE return NOT-ONE-OR-TWO
John McCarthy mentions conditional expressions in this paper from 1958: https://www.softwarepreservation.org/projects/LISP/MIT/AIM-0...

In external LISP syntax a COND would have looked similar to this:

  (x>1 -> 1, x=0 -> 0, x<1 -> -1)
Above would mean:

  if X greater than 1, then return 1
  if X is 0, then return 0
  if X is smaller than 1, then return -1


That’s a bit like saying that all languages with an imperative for loop keep reinventing FORTRAN. Although I give you that many of Lisp’s good ideas were unfashionable for a while, reusing those ideas isn’t in itself an “ad hoc” implementation of Lisp.


For what it's worth, this is essentially what Crystal does, though by introducing a union type instead of an optional.


The only problem is that it can't distinguish between nested if/else blocks at the type level, but it's probably fine


This is the way to go.


I don't like the "then" branch having a different type than what it actually returns. If you need to add an "else" branch you then also need to touch what the "then" branch returns. Seems like spooky action at distance.

I could maybe bear with "let foo = if true { Some(42) } /* else omitted */;" that would then give the automatic "None" else branch automatically. Bonus: it works the same with and without type annotations.


Rust could introduce a new trait similar to Default: let's say ElseDefault. Every type implementing this new trait could return the default value of the type of if branch when the else branch is missing and is triggered. Option<V> could implement ElseDefault and returns None as default value.

This could make `if x { Some(42) }` equivalent to `if x { Some(42) } else { None }`.


Currently we can write

    fn foo() -> () {}
And it returns an instance of (). I guess the same mechanism could be generalized for any value with, like you said, a trait like MissingValueDefault. That would then also work with the omitted else branch.

I'm not sure if there are some difficulties in calling different code based on type inference results, though. Are there other situations where that happens?


Doesn't seem very useful though, when you can just write that out, or use `bool::then`, or use some weird combinator on `Option` e.g. `Some(42).filter(|_| x)`.


Unless you want to return from within the filter with some error return code, or call an asynchronous function.


Agreed. I find Rust has already too much magic as it is.


Similarly, other control flow constructs could emit their own monadic types:

* A loop could evaluate to a generator that yields the value of each iteration sequentially (the lazy list/nondeterministic monad)

* An `async` block would evaluate to a future as async functions in many languages already (the async monad)

* A `try` block could evaluate to a result type, where calling result-returning functions inside the block would break from the block in case of an error value (the try/either monad).


Scala does this in two ways. For one-armed `if`:

- The type must be `Unit`

- Since `()` is the only value of type `Unit`, we can unambiguously infer that the `else` case must be `()`

- Since `Unit` conveys no information, there is never any reason to assign it to a variable

- The `Unit` type is commonly used to represent effects, like `print` (on the JVM it's equivalent to Java's `void` type)

These features conspire to make one-armed `if` expressions act like statements, since they will generally perform an effect and not be bound to a variable.

Scala also provides an `Option.when` method, e.g. `Option.when(x.hasFoo)(x.foo)`. This seems nicer than the `boolean.then` mentioned by the article, for a few reasons:

- The use of `Option` is explicit, rather than implicit

- The second argument of `Option.when` is passed by-name, so there's no need to manually wrap it in a closure/thunk


> Scala does this in two ways. For one-armed `if`:

That is exactly what Rust does:

    error[E0317]: `if` may be missing an `else` clause
     --> src/main.rs:2:13
      |
    2 |     if true { 42 };
      |     ^^^^^^^^^^--^^
      |     |         |
      |     |         found here
      |     expected integer, found `()`
      |
      = note: `if` expressions without `else` evaluate to `()`
      = help: consider adding an `else` block that evaluates to the expected type
The "note" line spells it out clearly, although the line annotations are a bit unclear.

> - The use of `Option` is explicit, rather than implicit

There's nothing implicit about Option, it's literally the concrete return type of the function.

That's like saying the boolean is implicit in Option::when.

> - The second argument of `Option.when` is passed by-name, so there's no need to manually wrap it in a closure/thunk

Rust does not have lazy arguments, arguments are always eagerly evaluated.

Thus it needs one lazy version which takes a function (bool::then), and optionally one eager version which takes a value (that's bool::then_some).


> That is exactly what Rust does

Cool, that's good to know (I've not used Rust (yet))

> There's nothing implicit about Option, it's literally the concrete return type of the function

For context, I was referring to this paragraph in the article regarding `.then`:

> I’m not a huge fan of the implicit introduction of the Option type. In a language like Rust, implicitly introducing Option is barely acceptable: Option is already a priveleged type, and ‘accidentally’ assigning such an optional if-expression to a variable will likely cause a type mismatch error, caught by the compiler.

---

> Rust does not have lazy arguments, arguments are always eagerly evaluated.

I thought as much, since it would be a glaring omission if Rust did have this feature but didn't use it for the argument of '.then' ;)

It also makes sense given Rust's goals of low-level, high-performance, substructural-typing, etc.: if extra wrapping/unwrapping is needed, better to make it explicit (and enforced by the types), rather than have the compiler slowing things down without warning.

Also, to be clear: the Scala version is actually pass-by-name, rather than lazy; e.g. the following function will evaluate its argument twice!

  def double(x: => Int): Int = x + x


Option<> type works well in Rust because compiler enforces its proper usage. In dynamic languages programmers are on their own


> because compiler enforces its proper usage

For languages where the compiler doesn’t help, is there any benefit? I’ve seen Option in Java projects where it just seems to add one more layer of could-be-null.


Java's optionals are incredibly half-baked IMO, and I say this as someone who is generally quite pro-Java.

They make the code slower, harder to read and don't really solve any problem.


Yeah, in particular since you cannot have Some(null) which is just very bad design because it makes interaction with code that uses null impossible (or unreliable).

And syntactically, it's not a pleasure to use, but that's more a fault of Java being so verbose and limited in terms of the typesystem, not a fault of the Optional type.


I don't think amending the type system (or using the existing type system) would make sense for Java.

I think either a null-delegating operator (like C#'s foo.?bar ); or a variable modifier (a bit like 'final') that doesn't permit nulls would make far more sense in the context of the language at large. In fact, the approach is commonly enforced through annotations and static analysis. Built-in support for the construct would be very beneficial.


Yeah, I agree.


The rule in Java, enforced by the programmer, not the compiler, is that you never assign an Optional to null. If a method returns Optional, you're free to assume it will never return null. If it does, you should track down and publicly shame the author of such monstrosity.


"If you do that, ideally, a small gnome climbs out the back of the computer and hits the developer with a mallet."

(various bits of perl IRC have adopted the mallet gnome as an idiom over the years and it seems to convey the "please don't" point without being overly combative ;)


There's always benefit, as long as programmers stick to it. Compilers and linters help a lot, but it's not impossible to do without them, just requires more discipline and effort. And you don't have that "it compiles" guarantee that makes you feel warm and fuzzy inside.


One important property of options is that they don't get flattened when nested. None and Some(None) are different.


If anyone wondering what that means for "optional ifs":

        if let Some(new_variable) = method_that_returns_optional_value {
            println!("value: {new_variable}")
        } else {
            println!("{new_variable} is not in scope, this wont't compile");
        }
I find it powerful. "if-let" and "while-let" are part of the language. OTOH rustaceans are happy with and constantly reasoning (constantly fighting the borrowchecker?) about scopes and if a variable / it's intended value is accessible at a certain line of the source code.


Maybe I’m misunderstanding the point of the example, but it really seems more like the else clause ought to be the if clause if it would have the more drastic effect at runtime. Checking if the denominator is zero and jumping out or returning some alternate value would be the if.


Haskell has `when cond do sideEffect` which captures the intention of an if without an else concisely.


What I don't like about these discussions is how they completely skip the pragmatic part.

That's why "expression-oriented" Erlang (and Elixir) simply has the single-branch if while the academics still debate whether or not it should, should be optional etc.


Those implementations are useful as empirical tests of what works/doesn't.

However, it's also useful to keep such academic debates going; since it's unlikely that our current abstractions are the most optimal. For example:

- Hoare added `null` to Algol because it was pragmatic; and he later regretted it. In contrast, "academics" like Milner added algebraic datatypes to ML, which turned out to provide a much safer alternative (Option/Maybe).

- "Pragmatic" languages like Python, Javascript, etc. added try/throw for exceptional control-flow. Then added for/yield for resumable control-flow. Then added async/await for concurrent control-flow. Meanwhile, "academics" refined Scheme's call-with-current-continuation into shift/reset, which allows all of those to be written in libraries, rather than having to keep changing the language implementation.

- Languages like ML and Scheme used call-by-value, which happened to make side-effects quite pragmatic. Meanwhile "academics" debated call-by-need semantics, Moggi figured out that effects are monadic, and Wadler added it to Haskell. That enabled a bunch of functionality that would have been undermined by the presence of side-effects (e.g. implicit parallelism, software-transactional memory, parser combinators, promises/futures, etc.)


> That's why "expression-oriented" Erlang (and Elixir) simply has the single-branch if while the academics still debate whether or not it should

Erlang's single-branch if is essentially an assertion, it throws if the one branch is not taken.

I don't find this very pragmatic, or even at all useful since erlang ships with a slew of assertion macros (https://www.erlang.org/doc/man/assert.hrl.html).

Your "pragmatic" also doesn't do anything useful for a statically typed language. A statically typed language requires that the program be well-typed, so you have to define what "well typed" implies for a single-branch if.




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: