Hacker News new | ask | show | jobs
by kentonv 913 days ago
Author of Cap'n Proto here.

The main innovation of Cap'n Proto serialization compared to Protobuf is that it doesn't copy anything, it generates a nice API where all the accessor methods are directly backed by the underlying buffer. Hence the generated classes that you use all act as "views" into the single buffer.

C++, meanwhile, is famously an RAII lanugage, not garbage-collected. In such languages, you have to keep track of which things own which other things so that everyone knows who is responsible for freeing memory.

Thus in an RAII language, you generally don't expect view types to own the underlying data -- you must separately ensure that whatever does own the backing data structure stays alive. C++ infamously doesn't really help you with this job -- unlike Rust, which has a type system capable of catching mistakes at compile time.

You might argue that backing buffers should be reference counted and all of Cap'n Proto's view types should hold a refcount on the buffer. However, this creates new footguns. Should the refcounting be atomic? If so, it's really slow. If not, then using a message from multiple threads (even without modifying it) may occasionally blow up. Also, refcounting would have to keep the entire backing buffer alive if any one object is pointing at it. This can lead to hard-to-understand memory bloat.

In short, the design of Cap'n Proto's C++ API is a natural result of what it implements, and the language it is implemented in. It is well-documented that all these types are "pointer-like", behaving as views. This kind of API is very common in C++, especially high-performing C++. New projects should absolutely choose Rust instead of C++ to avoid these kinds of footguns.

In my experience each new developer makes this mistake once, figures it out, and doesn't have much trouble using the API after that.

1 comments

apologies, perhaps i’m missing something here, having not used cap’n proto in any context at all before.

is it not possible to delete the rvalue reference overload of ‘getList’?

as far as i can tell, the error producing code wouldn’t have produced a diagnostic, but failed to build in the first instance, like the rust case?

That would catch some legitimate use cases, where you get the list and immediately use it on the same line. Admittedly this is not so common for lists, but very common for struct readers, e.g.:

    int i = call.send().getSomeStruct().getValue();
Here, even though `send()` returns a response that is not saved anywhere, and a struct reader is constructed from it, the struct reader is used immediately in the same line, so there's no use-after-free.

Someone else mentioned using lifetimebound annotations. This will probably work a lot better, avoiding the false positives. It just hadn't been done because the annotations didn't exist at the time that most of Cap'n Proto was originally written.

Oh actually there's a much more obvious case where prohibiting getters on rvalues would be a problem. It would prevent you from doing this in general:

    myReader.getFoo().getBar()
Here, `myReader` is already a view type; ownership of the backing buffer lives elsewhere. `getFoo()` returns a reader for some sub-struct, and `getBar()` returns a member of that struct. If we say getters are not permitted to be called on rvalues, this expression is illegal, but there's no actual problem with it and in practice we write code like this all the time.
i could be wrong, but i’m reasonably confident that this is UB for even trivial types? someone more knowledgeable with the language lawyering would need to opine one way or the other.

regardless of that outcome, i think i’d prefer to require a value preserving the lifetime of the reader/view. in the cases that it may not be necessary, i'd prefer to lean on the optimiser to take care of it..!

What's UB about it? Any temporary objects constructed during the evaluation of a statement live until the end of the statement. The standard is clear on that.

> i think i’d prefer to require a value preserving the lifetime of the reader/view. in the cases that it may not be necessary, i'd prefer to lean on the optimiser to take care of it..!

We'd all prefer APIs that cannot be used unsafely but realistically there's no magic the optimizer can do to make the problems with refcounting go away. You need to use a language like Rust to solve this.

ah, sorry, i didn’t read that correctly.

perhaps for values like this you’re fine. i think my point still stands about the reader of a built-in list/sequence type, surely?

and, not to sound facetious, that’s exactly what optimisers do :)

the c++ type system is more than capable about reasoning about lifetimes, the issue is that, with c++, it’s an optional part of the language. also, the lack of non-destructive moves. but to require both of those things in the language would require, essentially, the borrow checker in rust.

Unfortunately the C++ compiler cannot reason about much of anything as soon as you make a virtual function call, or even a call into a separate translation unit (unless maybe you are using LTO but that has its own issues).

E.g. if you do:

    {
      auto foo = std::make_shared<Foo>();
      bar->baz(foo);
    }
The compiler has to know what `baz()` does in order to know whether it can elide heap allocation and refcounting of `foo`. `baz()` could, after all, add a refcount on `foo` and keep it somewhere.

If `baz()` is virtual, or just implemented in a source file that the compiler cannot see at the time of compiling the calling code, then there's no ability to optimize at all. Even if the compiler does know the full implementation of `baz()`, eliding the heap allocation is not going to be easy. Maybe if `baz()` is very simple, it can do it? I actually don't know if the compiler is even capable of this when using shared_ptr.

Of course you can always say "well a sufficiently smart compiler could reason about your whole codebase including every implementation of a virtual call" but we program to the compiler we have, not the one we want. And frankly, if you had a compiler that smart it would be able to detect your use-after-free bugs and warn about them, so you wouldn't need to use shared_ptr everywehre.