Hacker Newsnew | past | comments | ask | show | jobs | submitlogin

>Ideally that should be zero-overhead

Optionals are not zero-overhead in any other language. If you aren't going to bother checking the optional, why not just return any old pointer type? The type system should be used to enforce a contract and bypassing checks on that feels like you are just spitting in the fact of the type checker.



> Optionals are not zero-overhead in any other language.

That's been true for so many other features in C++ too.

> If you aren't going to bother checking the optional, why not just return any old pointer type?

Pointers require something to point to. Optionals can embed the value.

> The type system should be used to enforce a contract and bypassing checks on that feels like you are just spitting in the fact of the type checker.

It's bypassing something alright, but I wouldn't say that's the type checker. And I mean, you could use this logic for everything else. Iterators, arrays, etc. should all have bounds checking too, right? If you want that, there's C#...


>That's been true for so many other features in C++ too.

Plenty of C++ features are zero-cost, not necessarily zero-overhead. To me having an optional without bounds checking is like obtaining a shared_ptr without incrementing it's use_count. It's like arguing that I should increment use_count manually so that copies are "zero-overhead". Sure it's "faster" but the design is still broken and will lead to all sorts of issues.

>Pointers require something to point to. Optionals can embed the value.

So? Just because it embeds a value rather than a pointer doesn't make your program any more correct. If the API designer returned an empty optional and you access it anyways you are still dealing with garbage data.

>And I mean, you could use this logic for everything else. Iterators, arrays, etc. should all have bounds checking too, right? If you want that, there's C#...

Ok? Then lets remove all overhead from the language. Why does shared_ptr increment it's refcount behind my back? No more shared_ptr. This kind of argument without nuance gets you nowhere. Likewise, unlike bounds checks, nothing about std::optional is forced or baked into the language. If you don't want to do null checking, then just don't use std::optional - the fast option is still there and unlike Rust, the fast option is the default.

Optionals are a great tool to eliminate null pointers from a codebase. It's a big enough problem that I would expect an optional type, designed in 2018 to get it right. Allowing automatic dereferencing is an oversight.


> And I mean, you could use this logic for everything else. Iterators, arrays, etc. should all have bounds checking too, right? If you want that, there's C#...

I mean yes, the logical conclusion is that trying to backport safety onto C++ is impossible and moving to another language is the only reasonable option. That's kind of the point of the article.


> I mean yes, the logical conclusion is that trying to backport safety onto C++ is impossible

The logical conclusion is that what some people here seem to want is so radically different from C++ that they really just want an inherently just a different language altogether... which is fine. Nothing about this implies it's impossible to shoehorn C++ into something else (though that may still be true; I'm not sure). It just implies that another language should be considered when you want to prioritize memory safety, like maybe C# or Rust. (And nowhere here am I agreeing or disagreeing with the article.)


  > why not just return any old pointer type?
Using a pointer type in a straightforward manner would entail allocating the object on the heap.

If allocated on the stack it has obvious lifetime limitations.


> Optionals are not zero-overhead in any other language.

This is a common misconception, and a pet peeve of mine. Properly designed optional types add neither space nor code-size overhead.

Consider the C function:

    foo(int* ptr) { ... }
If it is part of the API of foo that `ptr` may be null, then foo must not dereference `ptr` without verifying that it is non-null. So the body of foo must be something like:

    foo(int* ptr) {
      if (ptr != NULL) {
        ...
      }
    }
Or, take the non-pointer case:

    foo(struct S s, bool s_present) { ... }
Clearly the intent of this function is that the contents of `s` should not be accessed if `s_present` is false, so the implementation of the function must check `s_present` before inspecting `s`. (Inspecting s incorrectly wouldn't necessarily be UB, like in the previous example, but it's clearly erroneous)

The only thing that a good optional type implementation does is make it a type error to fail to do what everyone agrees both of those functions must do anyway in order to be correct. It need not increase the size of the representations, nor must it emit a single unnecessary instruction when compiled. There are plenty of languages that do this, Rust being a prominent example.


> If it is part of the API of foo that `ptr` may be null, then foo must not dereference `ptr` without verifying that it is non-null. So the body of foo must be something like:

    foo(int* ptr) {
      if (ptr != NULL) {
        ...
      }
    }
then what ?

    foo(int* ptr) {
      if (ptr != NULL) {
        *ptr += 1; // optional<T>::operator* would introduce a branch here ?
        *ptr = *ptr / 2 ; // same 
        if(*ptr > some_constant) // same
        { ... } // etc etc 
      }
    }
and compiler optimizations can't always be assumed, for instance debug mode (-O0) performance does matter


That’s why I said properly implemented! Good optional implementations let you do the check once to unwrap the inner value, so you have one branch.

In rust:

    fn foo(s: Option<&S>) -> {
      if (let Some(inner) = s) {
        // now you can access inner repeatedly
        // without branches
      }
    }
(edit to fix syntax errors)


but this declares a new variable - I find this super messy and really decreases readability in practice since now it's not obvious anymore that what you're working with was a function parameter. Also it adds a scope level - I much prefer the

   if(!bla)
     return;

   // use bla
early-return style. So that's really a no-go for me from years comparing the two styles


Well if you have aesthetic objections to the way rust does it, I can't argue with you. Kotlin does it the way you like, though.

Anyway, I started in on this thread because I was objecting to the claim that optional types always have overhead. They don't. That's all I wanted to show.


  > Well if you have aesthetic objections to the way rust does it,
I agree with GP that it is harder to maintain ("messy" they say) if there is more than one variable referring to the same value. It is not so subjective as you make it to be.


The idiomatic way to solve that in rust is to re-bind to the same variable name.

   if let Some(foo) = foo { /* ... * / }
That's possible because in Rust name shadowing

    let foo = grab_foo_bytes();
    let foo = parse_foo_bytes(foo);
makes the previous binding of the variable no longer namable and thus no longer accessible, but doesn't drop it (and trigger RAII destructors).

Now someone will probably come in and say "oh no, this isn't exactly like c, how will anyone ever understand it". To that I reply why is it that c users get to say "if you don't know how c works exactly you're holding it wrong" and then comment about other languages "I don't want to have to learn anything to hold it right".


  > The idiomatic way to solve that in rust is to re-bind to the same variable name.
OK, that's reasonable. Is the idiomatic way to use optionals to introduce a layer of nesting? I prefer keeping functions very "flat"-looking. It sounds like Rust's optionals will give people an excuse to create labyrinthine functions where I'm constantly scrolling around to remind myself of what level of nesting I'm at and whether I'm in a loop or not, etc.

As you say, you don't need a new block to shadow a previous var, so hopefully that style catches on.

  > "oh no, this isn't exactly like c, how will anyone ever understand it"
Not very persuasive, sure, but the network effect of the C/C++ culture (including its general syntax and imperative nature) is a strength in and of itself. New languages would do well to coddle the existing C++, Java et. al. users wherever it doesn't contradict the language's central mission.


> I prefer keeping functions very "flat"-looking.

I definitely agree. One way I do that is by having an internal function that takes a valid value and a public function that does the validating/error handling.

That doesn't always make sense though. There's a few other idiomatic ways to avoid nesting. Since statements evaluate to values, you can write

    let foo = if let Some(foo) = foo {
        foo
    } else {
        // Something that either evaluates to the same type as foo or returns early
    }
That's so common there's a special operator for it, ?. It essentially either early returns the sad path or evaluates to the happy path.

    fn get_foo() -> Option<Foo>;

    fn frob() -> Option<Bar> {
        let foo = get_foo()?;
        let bar = convert_to_bar(foo);
        Some(bar)
    }
I prefer to use Result to model missing data like cases instead of Option because it composes better. So that might be

    fn get_foo() -> Option<Foo>;

    fn frob() -> Result<Bar, BarNotFound> {
        let foo = get_foo().ok_or(BarNotFound)?;
        let bar = convert_to_bar(foo);
        Some(bar)
    }


    #[derive(Debug, thiserror::Error)]
    #[error("Bar not found")]
    struct BarNotFound;
That last bit uses a stdlib macro and a very commonly used external lib macro to save a few lines of repetitive typing.

Edit: Also ? doesn't special case Result and Option. You can make your own type conform to the interface (trait) it requires. That would probably be weird though.


Interesting. Thank you for sharing.


It's exactly like Lisp:

  (let* ((x (this))
         (x (that))
         ...)
    ...)
In C you cannot even do:

  { int x = 42;
    { int x = x + 1; // x + 1 refers to this second x
      ... } }
This is because the scope of the identifier being declared already starts at the =. So even if the redeclaration were allowed without opening a new block scope, it wouldn't work.

However, there is a good reason for that: initializers can be self-referential, so they have to have their own identifier in scope:

   // define circular structure in one step, no assignments:
   struct node n = { .next = &n, .prev = &n };
In this regard, the scoping rule is like letrec in Scheme or labels in Common Lisp.




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

Search: