Let's say you have a header lib.h:
inline int foo(int i) {
assert(i > 0);
//...
}
In C, this function is unspecified behavior that will probably work if the compiler is remotely sane.In C++, including this in two C++ translation units that set the NDEBUG flag differently creates an ODR violation. The C++ solution to this problem was a system where each translation unit enforces its own pre- and post- conditions (potentially 4x evaluations), and contracts act as carefully crafted exceptions to the vast number of complicated rules added on top. An example is how observable behavior is a workaround for C++ refusing to adopt C's fix for time-traveling UB. Lisa Lippincott did a great talk on this last year: https://youtu.be/yhhSW-FSWkE
There's not much left of Contracts once you strip away the stuff that doesn't make sense in C. I don't think you'd miss anything by simply adding hygenic macros to assert.h as the author here does, except for the 4x caller/callee verification overhead that they enforce manually. I don't think that should be enforced in the standard though. I find hidden, multiple evaluation wildly unintuitive, especially if some silly programmer accidentally writes an effectful condition.
I think these conditions should be part of the type signature, different to what was suggested in the otherwise good talk you cited.
In general though, the compiler can't optimize across the translation unit boundary without something like LTO. The code for the callee might have already been generated by the time the caller sees that the precondition is statically satisfied.
> In general though, the compiler can't optimize across the translation unit boundary
Which is why I would put it in the function signature, so it is available in both translation units. Making the code match the function signature is currently generally the responsibility of the caller. For example when I declare an argument of type double and write an integer in the call, the compiler will convert it to a double on the callers side. I think the safety story will be similar to a printf-call today. A dumb compiler does nothing, the smart compiler adds a warning/error, when the precondition fails.
My understanding is, that on the callee's side this case is simply undefined behaviour. Much like it is today for example, that you can't pass a NULL everywhere, it might be declared to be UB, but currently this is only documented and internal to the callee and not documented in the function signature.
PS: This does not conflict with my other comment (https://news.ycombinator.com/user?id=1718627440), that this can't be implemented as a macro that invokes UB:
callee (void * p, [...])
contract_assume (nonnull (p), "p can't be NULL!")
{
p[0] = foo; -> UB
}
The access through p simply becomes UB like it always was. But the contract_assume, can't be UB, since then the check and diagnostic is omitted or reordered.So these declarations might coexist without issue even though they have different signatures:
extern int foo(a, b); // in include/lib.h
int foo(int a, int b); // in src/foo.h
whereas this would be incompatible const int foo(int a, int b); // "nearly" compatible
If you attach things to the prototype, then you need to sort out the compatibility rules. If contract_assume(a > 0) changes the type, the extern shouldn't be compatible. This is frequently used to allow linking against libraries compatible with older language standards while allowing newer code to benefit from newer standards like C99, C11, or C23.The C23 committee ran into this issue when introducing attributes. Their solution was just exclude attributes from the signature and say they're always compatible:
Although semantically attached to a function type, the attributes described are not part of the prototype of such a function, and redeclarations and conversions that drop such an attribute are valid and constitute compatible types.
I can happily declare two completely incompatible functions with the same symbol name, as long as they are in separate TUs and I don't use -flto, neither the compiler nor the linker will complain and my program will simply be garbage. This won't change with incompatible contracts.
When I both show them to the compiler, when they contradict, the compiler will complain, that also doesn't change.
Of course this will not work:
extern int foo(a, b);
int foo(int a, int b) contract_assume(a > 0);
However this will: extern int foo(a, b) contract_assume(a > 0);
int foo(int a, int b);
But this isn't a problem, since this is precisely the feature we want to introduce contracts for: catching function call mismatches that are not yet expressible in the language.> while allowing newer code to benefit from newer standards
Having no contract specified should of course result in no additional restrictions being exposed beside this already present now. This wouldn't be possible:
foo(unsigned int a) contract_assume(possible(a < 0))
But I don't think anybody is arguing for that.This is a huge challenge for a C-like language with pervasive global state. Might be more feasible for something like Rust, but still very difficult.
I find the general concept incredibly useful, and apply it in the more general sense to my own code, but there's always a bit of "what do I actually want contracts to mean / do here" back-and-forth before they're useful.
PS: I do like how D does contracts; though I admit I haven't used D much yet, to my great regret, so I can't offer my experience of how well contracts actually work in D.
A good contract system may in fact rely on type-safety as part of its implementation, but types do not necessarily cover all aspects of contracts (unless you're referring to the full gamut of theoretical generalisations of things like dependent types, substructural types, constraint logic programming, etc), and are also not necessarily limited to things that only apply at compile-time.
I think that implementations trying out their own experimental features is normal and expected. Ideally, standards would be pull-based instead of push-based.
The real question is what prevented this feature from being proposed to the standardization committee.
Ironically, over the years, C++ has adopted many features popularized by D.
(like contracts!)
C++ should adopt a few more D features, like https://www.digitalmars.com/articles/C-biggest-mistake.html, compile time expression evaluation (C++ did it wrong), forward references, and modules that work. C++ should also deprecate the preprocessor, a stone-age kludge that has long been obsolete.
I think you are leaving out the fact that your comment applies to the post-C++98/pre-C++11 hiatus.
Once C++11 was released, the truth of the matter is that whatever steam D managed to build up, it fizzed out.
I'm also not sure if it's accurate to frame the problem with C++0x as picking up features from D. As I recall, D's selling point was that it was scrambling to provide the features covered by C++0x but users weren't forced to wait for a standard to be published to be able to use them. Once they could, there was no longer any compelling reason to bother with D anymore.
- compile time function execution
- modules
- no preprocessor
- memory safe arrays
- preprocessor replacement
- ranges
and so on.
C++ modules are indeed a mess, but you are fooling yourself if you believe that the preprocessor of all things is a compelling reason to switch. In fact, I think you unwittingly proved my point on how interest in D fizzed out the moment C++11 was released.
The preprocessor is an unhygienic, ugly mess. Just look at the system .h files, which should be a showcase on how to use it correctly. I stand by my assessment of it.
Stroustoup has one vote, not everything he advocates for wins votes, including having a saner C++ (Remember the Vasa paper).
Citation needed.
For starters, where is the paper?
[0] https://open-std.org/JTC1/SC22/WG21/docs/papers/2018/p0977r0...
Also WG14 famously killed Dennis Ritchie proposal to add fat pointers to C.
Language authors only have symbolic value once they relish control to a standards body.
But if I want to use Eiffel, I’ll use Eiffel (or Sather).
I’d rather C remained C.
Maybe that’s just me?
C especially was designed with lots of security defects, and had it not been for UNIX being available for free, it would probably never taken off.
Most C developers don't want a modern C, they want a reliable C. WG14 should be pushing for clarifications on UB, the memory and threading model, documenting where implementations differ, and what parts of the language can be relied and what not.
Nobody really needs a new way to do asserts, case ranges, or a new way to write the word "NULL".
I think this talk about "complexity" is a red herring. C++ remains one of the most popular languages ever designed, and one of the key reasons is that since C++11 the standardization effort picked up steam and started including features that the developer community wanted and was eager to get.
I still recall the time that randos criticized C++ for being a dead language and being too minimalistic and spartan.
> C++ has 3 reasonable implementations, C has hundreds, for all sorts of platforms, where you don't get anything else.
I don't understand what point you are trying to make. Go through the list of the most popular programming languages, and perhaps half of them are languages which only have a single implementation. What compelled you to criticize C++ for having at least 3 production-quality implementations?
> Most C developers don't want a modern C, they want a reliable C.
You speak only for yourself. Your personal opinion is based on survivorship bias.
I can tel you that as a matter of fact a key reason why the likes of Rust took off was that people working with low-level systems programming were desperate for a C with better developer experience and sane and usable standard library.
> Nobody really needs a new way to do asserts, case ranges, or a new way to write the word "NULL".
Again, you speak for yourself, and yourself alone. You believe you don't need new features. That's fine. But you speak for yourself.
The "most popular programming languages" are irrelevant here.
C and C++ are standardized languages, and also the tools we use for code that actually matters. A standard that can't be implemented is worthless, and even the "3 high quality" implementations of C/C++ haven't fully implemented the latest 2 editions of either language.
There's a lot more riding on these two languages than you give credit for, and they should be held to a higher standard. C is not the language to experiment with shiny new features, it's the language that works.
> I can tel you that as a matter of fact a key reason why the likes of Rust took off
So what's the problem? If Rust is gaining traction on C/C++, and people are excited about what it brings to the table, use it. We'll both do our thing, let it play out - we'll see which approach yields better software in 10 years.
I think this belief is based on faulty assumptions, such as survivorship bias.
C++ became popular largely because it started off by extending C with the introduction of important features that the developer community wanted to use. The popularity of C++ over C attests how much developers wanted to add features to C.
C++ also started being used over C in domains where it was not an excellent fit, such as embedded programming, because the C community prefered to deal with C++'s higher cognitive load as an acceptable tradeoff to leverage important features missing from C.
The success of projects such as Rust and even Zig, Nim also comes at the expense of C's inability to improve the developer experience.
Not to mention the fact that some projects are still developed in C because of a mix of inertia and lack of framework support.
So to claim that the C programmers do not want change, first you need to ignore the vast majority that do want but already dropped C in favor of languages that weren't frozen in time.
It's also unbelievable to claim that a language that precedes the concept of developer experience represents the apex of language design. This belief lands somewhere between Stockholm syndrome and being mentally constrained to not look beyond a tool.
Zortech C++ is what gave C++ critical mass to succeed.
P.S. before ZTC++, the traffic in the usenet C++ newsgroup was neck-and-neck with the objective C newsgroup. After ZTC++ was released, traffic in the C++ newsgroup took off and the objective C one faded away. Borland saw our success, and pivoted away from their nascent attempt at an OOP language towards implementing Borland C++. Microsoft then also abandoned their OOP C project (called C) in favor of developing C++.
(I've never been able to get any information about C, I was just told about it by a Redmondian.)
Good, we can ignore them. It's not a language for everybody, and if you're happily using C++, or Zig, or Nim, keep doing that.
Developer experience is a weigted sum of many variables. For you cool syntax features may play a huge role of that, for most C programmers a simple language with clear and understandable semantics is much more important.
There are many languages with cool syntax and shiny features, and very few of the latter kind. C belongs to the latter, and it also happens to be running a vast majority of the world's most important software.
You keep bringing up Rust as an example. It's probably the most famous of the new-age systems languages. If it's such a great language, when will we see a useful program written in it?
Who do you think you're representing? At best you only speak for yourself. It's perfectly fine if you choose to never update any tool you use, but that's just your personal opinion. You are free to stick with older standard versions of even compiler releases, but that is no justification to prevent everyone around you to improve their developer experience.
> It's not a language for everybody (...)
You might believe it isn't, but that's hardly a sane or rational belief.
1. A lot of people use the old C standards.
2. Not a lot of people use the new ones.
3. A lot of useful software is written in C.
4. Not a lot of useful software is written in any of the other languages you've listed in this conversation, despite the fact that you can hardly call them "new" at this point.
I'm done with you, I'll leave you to puzzle out the obvious conclusion of these 4 points.You write software your way, I'll write it mine, and in 10 years we can check our homework. The first 10 years of Rust haven't really given us any results software-wise, but I'm sure with language design powerhouses such as yourself on the case, and just a few more pieces of syntax sugar, you can turn it around.
I think it should have been simple enough to find examples, though I suppose there might be some dependence on what you mean by "useful".
For standalone stuff, some examples might be Ripgrep, ruff, uv, Alacritty, and Polars. Rust is also used internally by some major companies, such as Amazon, Dropbox, Mozilla, Microsoft, Google, Volvo, Discord, and CloudFlare.
I should've been clearer about that, but what I mean by that is pretty much what a normal non-technical person would consider an useful piece of software - Photoshop, Figma, Excel, Chrome, Windows, Android, Blender, AutoCAD, Unreal Engine, any Office Suite...
Since this is a technical forum I think we'd both easily agree on a bunch of very technically impressive software that the average person hasn't heard of - ffmpeg, qemu, LLVM, Linux, Postgres, V8, etc.
It would be a stretch to put any of the tool on either of those lists. Given the popularity of Rust, and that it's now over 10 years old, I'd expect at least one major program that can serve as an example of "here's this very useful, complex software package, as proof that our methodology works and you can do cool things this way."
That seems like an... interesting... definition of "useful" to me. Why that definition?
> Photoshop, Figma, Excel, Chrome, Windows, Android, Blender, AutoCAD, Unreal Engine, any Office Suite...
To be fair, there is Rust in Windows and Android, and IIRC there's movement towards using it in Chrome as well.
> It would be a stretch to put any of the tool on either of those lists.
OK, but your second list has a different set of qualifications than the first. You originally just asked for "useful" programs, and that's what you asked for in the original comment I responded to. Now it's "very technically impressive". So which do you want?
I feel like it's probably not a bad idea to ask exactly what you mean by "technically impressive" as well, since I think it's hard to argue that ripgrep and polars don't at least have technically impressive parts in them.
> Given the popularity of Rust, and that it's now over 10 years old, I'd expect at least one major program that can serve as an example of "here's this very useful, complex software package, as proof that our methodology works and you can do cool things this way."
That seems like a bit of a questionable metric to me.
Given that Rust was explicitly designed and intended to be incrementally adoptable in existing codebases, it doesn't make sense to me to solely look for standalone programs since incremental adoption is very much part of "our methodology". This also sort of ties into your expectation in the first place - I'm not sure I'd expect the same given Rust's design and niche, as well as the general software landscape now vs. when C/C++ were a similar age.
I think there might be some mix between "we don't want any of the new features" and "we want the new features but can't". Probably hard to get good data on the precise split.
> A standard that can't be implemented is worthless, and even the "3 high quality" implementations of C/C++ haven't fully implemented the latest 2 editions of either language.
This is a bit black-and-white. Just because a standard isn't fully implemented doesn't mean the parts that are implemented can't be useful, especially if the "haven't implemented this yet" is more due to a lack of manpower/attention/desire/etc. than actual impossibility (e.g., libc++ and parallel algorithms/<charconv> vs. export template).
Editions are rather limited in what they support.
Try to design a crate that stays compatible across editions, while using libraries that have changed signatures across editions.
The crate itself keeps its own edition fixed.
As someone that remembers the t-shirts with "my compiler compiles yours" that some C folks used to wear, it is kind of ironic having that turned around on them.
There is hardly any C compiler worth using that isn't equally a C++ compiler .
In fact, there is any C compiler left worth using that hasn't been rewriten into C++.
Your question can be reflected back to you: if you want an ever changing languages, go to Java, C# or C++, why mess with C?
The same thing can be said for every other language, yet they change.
So unless they are stuck on a pre-historic fork, they support C11 as much as clang and GCC do.
Exception for stuff like PIC, Z80,...
Which didn't even support proper C on their glory days.
f(int n, int a[n])
Actually do what it looks like it does. Sigh
What I want is -fbounds-safety from clang.
(and msvc is stuck on partial c11 support to this day, so imo, i don't quite think it's a fair target when comparing things to new features anyway)
What new clang feature are you talking about?
https://en.cppreference.com/w/cpp/container/span.html
Or if you want multidimensional span:
While there was no reason not to have .at(), lack of bound checks by default isn't a bad thing, as inlined bound checks have the potential to highly pessimize code (esp. in loops); also standard library hardening is a thing.
IMO there's much more value to be had in migrating C code (and pre-C++11 code, too) to C++ (or Rust, depending on one's tastes); RAII - that is to say, the ability to automatically run destructors on scope exit - and in particular shared_ptr/unique_ptr/bespoke intrustive pointers drastically reduce the risks of use-after-free
This way the indexing operation itself doesn't need to have bounds checks and it's easier for the compiler to optimize out the checks or for an "unchecked" section to be requested by the programmer.
For what it's worth, something similar was proposed in the C++26 core profiles paper [0] and that particular bit got some pushback. From a reponse paper [1]:
> The paper’s suggested approach includes adding bounds checking to containers solely based on a duck-typed deduction using the availability of operators and member functions without knowing for sure what purpose the container serves, what those members do, or even whether the class is, in fact, a container at all. [P3081]’s proposal would cause a wide variety of existing, perfectly valid code to fail unpredictably at run time:
> — Containers that are not zero-indexed
> — Sparse containers
> — Map-like types the compiler cannot detect as being map-like
> — A two-color image where size() is in bytes but for which indexing is by pixel (i.e., by bit)
> — Ring-like containers
> — Containers that automatically grow.
> [P3081] also fails to consider that some library vendors already (and will continue in the future to) choose to put contract preconditions on their operator[], which would result in double and possibly inconsistent checking.
from reading about contracts for C before i assumed it would be like what cake[1] does, which actually compile time enforces pointer (non)nullability, as well as resource ownership and a bunch of other stuff, very cool project, check it out if you haven't seen it yet :)
#define contract_assume(COND, ...) do { if (!(COND)) unreachable(); } while (false)
But this means that the compiler is allowed to e.g. reorder the condition check and never output the message. (Or invoke nasal demons, of course).This doesn't make much sense. I get that you want the compiler to maybe do nothing different or panic after the assertion failed, but only really after triggering the assertion and the notion of after doesn't really exist with undefined behaviour. The whole program is simply invalid.
To the brain of a compiler writer UB means "the standard doesn't specify what should happen, therefore I can optimize with the assumption UB never happen." I disagree that this is how UB should be interpreted, but this fight is long lost.
With that interpretation of UB, all `unreachable()` means is that the compiler is allowed to optimize as if this point in the code will never be reached. The unreachable macro is standard in C23 but all major compilers provide a way to do it, for all versions of the language.
So if you have a statement like `if (x > 3) unreachable()` that serves as both documentation of the accepted values, as a constraint that the optimizer can understand - if x is an unsigned int, it will optimize with the assumption that the only possible values are 0,1,2.
Of course in a debug build a sane compiler would have `unreachable()` trigger an assert fail, but they're not required to, and in release they most definitely won't do so, so you can't rely on it as a runtime check.
Exactly. But we already have unreachable and assert. The whole point of contracts is, that they are checked by the compiler (when the compiler invoker asks for it).
Having the contract invoke UB in the fail case means that instead of replacing the error return with a diagnostic provable by the compiler, you replace the error return with potential corruption. In which case is that ever the right choice?
I cannot, in good conscience, use a technology that adds even more undefined behavior. Instead it reinforces my drive to avoid C whenever I can and use OCaml or Rust instead.
It's also a good thing to tell the compiler that the programmer intends that this case will never happen, so that the static analyzer can point out ways through the code, where it actually does.
Given the examples, the author wants to ensure that 0 is not a possible input value, and NULL is not a possible output value.
This could be achieved with a simple inline wrapper function that checks pre and post conditions and does abort() accordingly, without all of this extra ceremony
But regardless of the mechansim you're left with another far more serious problem: You've now introduced `panic` to C.
And panics are bad. Panics are landmines just waiting for some unfortunate circumstance to crash your app unexpectedly, which you can't control because control over error handling has now been wrested from you.
It's why unwrap() in Rust is a terrible idea.
It's why golang's bifurcated error mechanisms are a mess (and why, surprise surprise, the recommendation is to never use panic).
But these contracts don't make things better.
Now you're removing control from the user. So now if an allocation fails, you crash. No way to recover from it. No getting an error signal back (NULL) so that you can say "OK, I need to clear up some memory and then try again". (Note that I'm not saying that inline error signaling such as NULL is good design - it's not).
Nope. No error handling. No recovery. You crash. And ain't nothing you can do about it.
That's just bad design on top of the existing bad design. Things that crash your app are bad. No need to add even more.
Do I want my app to crash at all? No, of course not. If it's crashing, there's a serious bug. At least now I know where to look for it.
Should we pass back up an error signal instead of crashing? Yes, if it all possible, do that instead. Sometimes it's not possible or not worth the hassle for something you're 99.99999% sure can't/won't happen. Or literally can't currently happen, but you're afraid someone on the project might do a bad refactor at some point 5 years down the road and you want to guard against some weird invariant.
Having the stdlib expose both ways, means either having two stdlibs (like on MS Windows), or dynamic checks in the stdlib. I don't think either way is a good idea. Thus, these contracts can't be used by libc.
Panics are like goto: Only useful in VERY rare circumstances. Every use of a panic should require a rock-solid justification for why you're choosing to crash the process - similar to how every call to abort() requires justification in any professional C codebase. assert() in production code was a horrible idea because liberal use was actually encouraged.
Rust's panic mechanism was almost good. Unfortunately, they chose to give them all innocuous names like unwrap() rather than or_panic(). So now you have to check for it all the time using clippy because it's too easy for a human to accidentally gloss it over. Linting usually points to a design failure in the language UX.
A lot of important programs (like the Linux kernel) don't operate strictly on the exact letter of the standard's UB semantics. They do things like add compiler flags to specify certain behaviors, or assume implementation details.
If you think dealing with undefined behavior is easy and you assume that people have verified that their software triggers no undefined behavior at runtime is fair game, then you should grant that assumption in favor of Rust developers having done the same with their panics, because avoiding panics is child's play in comparison to avoiding UB.
I don't know what it is about panics that triggers some mania in people. UB does not interrupt the program and therefore allows memory corrupt and complete takeover of a program and the entire system as a consequence. C developers are like "this is fine", while sitting in a house that is burning down.
There used to be a pretty blatant hibernation bug with AMD GPUs on Linux that essentially crashes your desktop session upon turning your computer on from hibernation. I've also had a wifi driver segfault on login that forcibly logged you out so you couldn't login like 9 years ago. C doesn't magically fix these problems by not having an explicit concept of panics. You still need to write software that is correct and doesn't crash before you push an update.
There is no meaningful difference between a correctness bug and a panic triggering condition with the exception that the panic forces you to acknowledge the error during development, meaning it is more likely that the correctness bug gets caught in the first place.
What I said was that panics aren't always appropriate and the context to determine this doesn't exist at the language level.
I didn't say managing UB was easy and in fact I've argued diagnosing it is impossible directly with members of both language committees. I didn't say panics are never appropriate. They usually are appropriate. I didn't say I don't use rust because X, Y, Z. I write rust. Etc.
There is no meaningful difference between a correctness bug and a panic triggering condition with the exception that the panic forces you to acknowledge the error during development, meaning it is more likely that the correctness bug gets caught in the first place.
More likely, but not guaranteed. I don't want to engage more with you, but there was a specific incident I was thinking of when I wrote the prior post that involved an assert improperly placed in a real time control loop that burnt out a very expensive motor.