Show HN: Modifying Clang for a Safer, More Explicit C++ (github.com)
Inspired by the paper "Some Were Meant for C" by Stephen Kell, I decided to show that it's possible to iterate C++ to be safer, more explicit, and less error-prone.
Here's a possible starting point: I didn't invent a new language or compiler, but took the world's best compiler, clang, and modified it to begin iterating towards a new furture of C++. Naming things is hard, so I call this 'Modified C++'. Some of the following could be implemented as tooling in a linter or checker, but the idea is to update the compiler directly. I also wanted to learn more about clang. This compiler needs a flag to enable/disable this functionality so that existing library code can be used with a 'diagnostic ignored' pragma.
You can build clang using the normal non-bootstrap process and you'll be left with a clang that compiles C++ but with the following modifications:
- All basic types (excluding pointers and references) are const by
default and may be marked 'mutable' to allow them to be changed after
declaration
- Lambda capture lists must be explicit (no [&] or [=], by themselves)
- Braces are required for conditional statements, case and default
statements within switches, and loops
- Implicit conversions to bool are prohibited (e.g., pointers must be
compared against nullptr/NULL)
- No goto support
- Explicit 'rule of six' for classes must be programmer-implemented
(default, copy, and move c'tors, copy and move assignment, d'tor)
- No C style casts
Here's an example program that's valid in Modified C++: mutable int main(int, char**)
{
mutable int x = 0;
return x;
}
Here's another that will fail to compile:
mutable int main(int, char**)
{
int x = 1;
x = 0; // x is constant
return x;
}
I'd like your feedback. Future changes I'm thinking about are: - feature flag for modified c++ to enable/disable with 'diagnostic ignored'
pragma, to support existing headers and libraries
- support enum classes only
- constructor declarations are explicit by default
- namespaces within classes
- normalize lambda and free function syntax
- your ideas here
90 comments
[ 4.5 ms ] story [ 170 ms ] threadIt might be interesting to do this on an existing codebase just to see where mutable is needed.
If you're not changing how const works, then this has limited utility in C++ because C++ const has all sorts of problems (e.g. not transitive). Also, what does the "mutable" annotation for a free function (i.e. main) mean? That just seems weird.
> - Lambda capture lists must be explicit (no [&] or [=], by themselves)
[&] is pretty valuable in cases where you do something like invokeSynchronously([&] {...})
I don't know that your changes will ever see much adoption because it won't be able to compile anything more complex than a "hello world" program as all the things you disallow are used & the porting effort is not cosmetic. Additionally, you're not actually fixing any of the problems people have with C++. So:
1. Consider fixing const semantics if you're going down the path of defining a new language
2. Think about how to fix memory safety and issues around UB which are the #1 sharp edges for C++
I don't know if you're achieving the goal of a safer, less error-prone language with the changes outlined. Have you looked at the things Carbon [1] is doing? I'd say that's an attempt to define a spiritual successor to C++, one that can easily interoperate with C++ but when you stay within the language it's safer.
[1] https://github.com/carbon-language/carbon-lang
WRT const, you're correct and I'd need to go further in updating const behaviors in the language. I stole this idea from Rust (sort of) in that variable declarations in that language are const by default. Essentially, I wanted to 'flip' the semantics in C++ to match, and use mutable to allow variables to change after their declaration. I could go further to enforce transitivity (e.g. so you can't do something like mutable x = y; where y is const).
[&] is handy indeed yet this was motivated by my experience in legacy heavy codebases where there are often many variables in scope and some with external consequences (e.g. file descriptors, sockets). I don't want these accidentally captured if the lambda invocation site has lifetime implications beyond those resources.
I think I'm achieving the goal of a safer, less error-prone language because these changes could've prevented the 2014 'goto fail' from happening (and not just because the keyword goto would be omitted but because there was a conditional without braces in the affected source making the code less explicit and less clear).
I think your solution to that problem goes the wrong way, though. The problem is whether or not a lambda can survive past the immediate usage, not what it captures. Listing those resources explicitly still gives you the same bug, banning [&] didn't avoid it.
I'd suggest instead an approach where a template taking a callable annotates whether or not it's "inline". If it is inline, then [&] should just be the default even. If it's not, then ban [&]. Possibly ban taking anything by reference if it's not a synchronously-used lambda even.
(inline / non-inline terms here cribbed from Kotlin https://kotlinlang.org/docs/inline-functions.html - probably there's a better word for it, but whatever)
https://docs.swift.org/swift-book/LanguageGuide/Closures.htm...
A non-"pure" const member function with const parameters could still call some other function with access to a "mutable" alias to what you have a const reference to. You would need something more, such as an ownership system to make the compiler make that impossible (as in Rust) or to detect it (the topic of Master's thesis, BTW ) ... but then it would no longer be something resembling C++.
However, C++ is a huge language and if there's a way to enforce safety by using only a subset of the language + tooling to help you do that, your improvements could be adopted piecemeal by teams looking to level up their codebase a bit.
Many languages have a way to opt in to e.g. strict type checking on a per-file basis. It would be really cool to see these improvements implemented in such a way that existing codebases could gradually adopt them.
in general, lossy conversions should never, ever be implicit
Good idea. That would catch annoying bugs I rarely, but occasionally, have.
Do you have any concrete example of what you perceive as being "crufty legacy"?
Those are pretty much irrelevant since at least C++98, specially as not only are they used voluntarily but also under the hood they are already handled with explicit casts.
Is this the best argument there is to break backwards compatibility?
Constructors being explicit by default I 100% agree with, and there are other rules I could come up with too, but in general, you need to realize that a lot of the features in the language have legitimate use cases that you might simply have a hard time imagining. Therefore, coming up with useful rules without hampering useful functionality requires both (a) experience & playing around with the language to a greater extent than you might at your job, and (b) a great deal of thought on top of that.
If you would, I'd love to hear some of your rules as it's clear you have a lot of C++ experience. Can you send some along? Thanks!
Well, I think it "should" work in the sense that I shouldn't have to type "vector<mutable int>" just to get a vector of mutable ints. It's just too much typing for zero benefit. How exactly you make that work is a separate question; you can do it at both the the language and library level. A compromise might be to make 'mutable' be a storage class (like 'register', or like how it already is for class members) rather than a type qualifier. Note that even making it a storage class has a downside: 'return v;' will now copy-construct its output instead of moving it. You'd have to mess with the const rules to get around that. It might be possible but I'd need to think through the implications and actually play around with it for a while before I could suggest that it would actually work well.
> lambda captures [...] Is there a compromise here?
I don't know honestly. One idea could be to see if some dataflow analysis could tell you if the lambda might leak from the scope it's declared in, and you could warn on that. I think there are already tools (like clang-tidy, cppcheck, etc.) that give you warnings of this sort; I'm not sure if they fully handle this case though, you'll have to check and see if those handle the cases you want. It almost certainly won't be something you could whip up in a few hours, in case that's what you were hoping for.
> I'm not sure that goto is required when one could use do { ... } while(false); with break statements for cases where goto would've been used (not ideal, but again this is an iterative approach).
That's in no way a substitute for a goto. Sometimes you really do need the ability to jump in, not just jump out. Imagine a state machine/coroutine/etc.—it's not impossible to write them without goto, but sometimes you'd have to go through contortions and write unnatural/unmaintainable logic to write them without goto. Yes C++20 has coroutine support now but it's mediocre at best and isn't suitable for every use case.
> C style casts to void are useful for some memory operations but I'm not sure there's a case where they're required.
Edit: (void) isn't required anywhere I know of, but there are lots of places where it's helpful to have, and completely unhelpful not to have. Here's one:
Sure you can do static_cast<void>(p) but that's not buying you anything. It's not the end of the world, but it's just wasting your time and making your code more verbose to read. I don't have a problem with more verbose typing when it actually buys you something, but there are cases where it doesn't, and this is one of them.In fact, a better rule might #5 below. I'm not sure there's a reason to ban the C-style cast entirely; it could be much more useful and safer than it is now.
Meta-rule of thumb: you need to make sure you're familiar with the vast array of use cases and scenarios people encounter in real-world C++ before you can come up with rules for other C++ devs to follow. The committee itself has a hard enough time doing this for a good reason—because it's hard! If you are going to propose that some feature is unnecessary, it should be a conclusion you draw after you've already used that feature in its "most useful" context (and found a good alternative)—not before that. Most features have some very compelling use cases, so if you haven't found a compelling use case for a feature ("compelling" assuming you disregard any downsides it might have in other contexts) then there's a good chance you simply haven't come across it yet, rather ...
WRT lambda changes, I have no particular timeline for this project as it's something I took up on the side. Pointers kinda break any data flow analysis that could be done. For example, imagine an object that serializes itself in one translation unit and is deserialized in another using a different class (this is somewhat common in telecommunications code. Imagine a struct Header { ..., void end[0]; }; which is used to handle messages of variable length but with the same Header types).
Java does just fine without goto (or have they added that since 2010?).
I think code that is intentional is more effective than code that is accidental. That said, I'd rather suppress the unused parameter warning with the '#pragma diagnostic ignored' mechanism than use a cast mechanic that just happens to address a compiler issue.
Thank you for the great list of rules! I agree with them all. Minor nit: you can't use static_cast to cast away constness.
C++ is quite literally meant for use cases where Java (or Python or C# or Go or pretty much any other language) is not "just fine". And as I mentioned above, you CAN get by without goto. You just have to go through (go to?) contortions in certain cases without it that make the situation worse rather than better. (And there is no reason to believe such use cases are equally common across all languages, so keep that in mind. For example hardware contexts require dealing with explicit state machines a lot more than software contexts do, and C/C++ are used more in those contexts—to name just one example.) Remember Java was doing "just fine" without lambdas, and it's still doing "just fine" without templates, value types, manual memory management, and a million other things you find in C++. Even C was also doing "just fine" without generics and destructors, but then they realized they're missing out and finally added it.
You have to realize, goto is basically a religion nowadays. People want to believe goto has no legitimate use cases, because (I can only assume) they're scared someone will use it as an excuse to utilize it irresponsibly outside those contexts. Kind of like why some drugs require prescriptions, I guess. I can't stop people from believing what they want, but as far as facts go, it does have use cases that many people simply don't encounter, and I tried to list some of them in my comments above.
> Minor nit: you can't use static_cast to cast away constness.
You actually can! Check this out:
If this is surprising... I would take it as an indication that it's difficult to foresee what can be done even with the commonplace features in the language (in both good and bad directions), let alone the rare ones (like goto).• Breaking out of nested loops
• Clause after loop that has run to its end-condition without a break, return or throw. Python allows an 'else'-clause after a loop, but IMHO "default" would be a better keyword.
• Error handling (C++ has exception handling already, but there are alternatives)
In any of those cases, could the code have been rewritten without goto?
Reverse the default for typename. Currently some_class<T>::thing is assumed to be an expression where 'thing' is a variable, when we don't know which template pattern to use because there may be an explicit specialization on the T that the user chooses. Hence, we have to say "typename std::vector<T>::iterator it;" instead of just saying "std::vector<T>::iterator it;". Instead, reverse that and assume it's a type by default unless shown that it's an expression. You'll need a new keyword for that, replacing "typename".
Remove the promotion-to-int rules. Currently in C (and in C++)
can have UB as signed integer overflow because any math done on an object smaller than int gets promoted to int. (No, you can't fix this with "(((unsigned short)x) * ((unsigned short)y))" the promotion happens on 's LHS and RHS, if those have types smaller than int.) Beyond this, people seem to expect that the type of the variable declaration will appertains to the calculation on the right, but it doesn't. For instance people seem to think "float f = a + b;" can't overflow where 'a' and 'b' are ints, because the assignment is going into a float.I haven't thought this idea through completely yet. Extend pointer types to include a static allocation identity as part of the type. Address-of local variable or global variable should produce one of these pointers. A "static allocation identity" is a special-typed zero-size variable, so you can stick it in code or as a class member. You could have pointers that were guaranteed to be allocated by THIS allocation point, instead of pointing to every possible T in the program. I'll fake up a syntax, "tree_node ^ tree::node_alloc ". It's known not to alias any other TreeNode the program might have, it has to be attached to the allocation point owned by that specific "node_alloc" in that object. (Let me phrase it differently. A tree in C or C++ has pointers which can point anywhere as long as it's another tree node type. That could be pointing to a different tree, it could be a self-pointer, it could be pointing up the tree, and so on. If your tree_node class has an allocation root, you can say that the pointers are things allocated through this allocation root. They can not outlive the allocation root. They are distinct from the things allocated by other allocation roots, which are the same tree_node types, but different tree_node objects. The node's list of children is std::vector<std::unique_ptr<tree_node ^ node_alloc >> so it clearly only holds pointers it allocated itself.)
There's another problem with pointer related to the above. Some code I saw used a "T &get_or_default<K, V>(Container &c, K key, V &default);" and the problem was that people would call it with a temporary for the default, like "Value &x = get_or_default(mymap, key, Value());" and they'd be holding a dangling reference. If you could make that an error, that'd be great. Maybe we use a trick like the "allocation root" above and treat pointers or references to temporaries have different type from the local variable. Then get_or_default takes and returns a reference-to-temporary and attempting to assign that to a reference in a variable declaration fails. Unlike the previous "allocation root" idea where you indicate the only thing you accept, this would be a case where you accept all allocation roots except one, the "temporaries" allocation root.
As far as I know, no compiler takes advantage of...
There is nothing wrong with [&] for short-lifetime lambdas. Lambdas passed to std algorithms or immediately invoked lambdas come to mind.
edit:
Are data members also const by default? How do I declare a non-const data member that is const when accessed within a const member function? (so non-const non-mutable in original c++)
But here you didn't tackle the main thing: a plan to make simple business logic not corrupt memory and cause havok with UB! Dereferencing arbitrary pointers is a dangerous operation that shouldn't be done in everyday code. If I'm writing data structures I'm willing to think about UB, but if I'm choosing the color of a widget I'm less so. I'm not expecting you solve this hard problem, but at least a general direction or a half solution that works for a % of the cases would be cool (or at least state this is a long term goal).
And of course there's the comparison to Rust, but Rust is actually just a data point in this solution space and perhaps new languages can afford to try new approaches
FWIW, for me, this is an anti-feature, and I would not use this language because of it. The net effect of this would be that I type "mutable" all over the place and get very little for my effort.
I've spent a significant amount of time understanding what the high-consequence programming errors that I make are, and "oops, I mutated that thing that I could have marked const" is a class of error that consumes a vanishingly small amount of my debugging time.
The errors I make that account for a large portion of my debugging time are errors related to semantics of my program. Things that, in C++, are typically only detectable at runtime, but with a better type system could be detected at compile time. The first step for this might be type annotations that specify valid values. For example, being able to annotate whether an argument is or is not allowed to be null, and having that enforced at call sites.
(NOTE: I also don't spend a meaningful amount of time debugging accidental nullptr values, but that's a good first step towards the type annotations I _do_ want)
You might want to consider adopting a more modern programming style for the benefit of your coworkers (and possibly yourself). Mutability all over the place is a nightmare, speaking as someone who currently has to work in a large codebase written like that. It's hard to predict what value a variable is going to have at any particular point in your code, since instead of only having to check where the variable is defined, you have to audit all the code between the definition and the use. For the same reason, it's hard to guarantee that your invariants are maintained, since there is a much larger surface area to check.
Just because some people think a particular style is useful doesn't mean everyone does. You might want to consider checking your biases before making comments like this. I understand the (many) arguments for using `const`, and I've concluded (for myself) that it's not a useful construct. Read and internalize the rest of my previous comment for more information.
I never type `const` in the first place. I don't find it useful, which was the point of my original comment.
> then either you work on something unusual
In C++, I've been working on game engines, compilers, interpreters and occasionally some reverse engineering. Not sure if that counts as unusual. I'm always thinking about perf, so if I can safely modify something in-place, I usually do.
> or you should learn to do better
I do just fine without `const`, thank you. Maybe you should learn to have a more open mind.
- allow for named arguments. E.g. let's say for the definition f(int a = 12, int b = 42), one might call f(b: 1337) or f(12, 1337). Not allowing mixing of named & positional is probably a good idea.
- take a look at static verification and remove language features that make static verification more difficult and think about how you could replace them (or remove; but e.g. function pointers & similar stuff fall in that category and are probably to powerful to be sacrificed this way)
//Edit: as others have said, try to whack as much undefined behaviour as possible (and in case you can't, don't accept the input program).
You probably know that, but "stealing" stuff from there is also a good idea. Plus, iirc, there are several additional warning options that are not contained in -Wall. Maybe you want to add some of these, too.
A "mutable" variable is contrasted to an "immutable" variable, not to a "constant".
You may not like the name "variable" for something that cannot be changed within a given scope, but it's still something that can take multiple values during the execution of a program.
This thing and Rust: "constant" -> something unable to change "mutable constant" -> a changeable constant...what? Even "mutable variable" -> a changeable thing that is a thing that is subject change doesn't make much more sense.
It is fine for "things" to be immutable by default, and in fact I think they should be. I just think "mutable" is keyword smell similar to "decltype" because type wasn't keyworded from the start.
"What?" indeed. Rust doesn't have "mutable constant". Rust's "const" is actually a constant, unlike in C where "const" means "Sort of immutable, although maybe not".
I guess maybe you've been told something about Rust like "let is kinda like C++ const" and so you've come to the erroneous conclusion that somehow "let mut" means "mutable constant" but that's just because you didn't really understand, blame either your attention or the poor explanation, it's surely nothing to do with Rust which has never said this is "mutable constant" since that's nonsense.
Move constructors/Move assignment should be noexcept by default. It's not entirely clear to me what a program ought to do if a move constructor/assignment operator throws an exception. In a general sense you cannot 'trust' the old object to not have been modified.
"All basic types (excluding pointers and references) are const by default" -- why the exception?
The rule of zero should be acceptable in addition to the rule of 6. Also, the rule of 5 is acceptable in many circumstances; lots of classes should not have default constructors. I agree that having 1,2,3, or 4 are bad, but 0,5,6 are acceptable.
"const" means "read-only", and probably should have been spelled "readonly".
"constant", as in "constant expression", means evaluated at compile time.
For example: `const int r = rand();` is perfectly valid: r can't be computed until run time (it's not constant), but it can't be modified after its initialization (it is const/readonly).
I would add: removing Unicode identifiers, because identifiers are meant to be identifiable.
On the topic of coding standards, there's an excellent github repo https://github.com/isocpp/CppCoreGuidelines which even quotes Bjarne Stroustrup as saying "Within C++ is a smaller, simpler, safer language struggling to get out." There are hundreds of recommendations, like 'ES.31: Don't use macros for constants or "functions"'.
So we have a collection of plugins, see here: https://cgit.freedesktop.org/libreoffice/core/tree/compilerp...
Which verify a variety of things.
We focus on 2 things: finding dodgy code and using APIs correctly. We don't try to modify the C++ language, just restrict accidentally straying into some of the really nasty corners.
But I like to keep an eye on experiments like yours for ideas :-)
e.g.
no c-style casts: https://cgit.freedesktop.org/libreoffice/core/tree/compilerp...
use the comma-operator sparingly: https://cgit.freedesktop.org/libreoffice/core/tree/compilerp...
is your loop variable really big enough: https://cgit.freedesktop.org/libreoffice/core/tree/compilerp...
calling virtual methods from destructors is dodgy: https://cgit.freedesktop.org/libreoffice/core/tree/compilerp...