There's a bit of a higher abstraction ceiling in Rust so in theory if you are successful rewriting a thing in Rust then you now have a codebase that's easier to change confidently.
This sort of property is nice to have in huge codebases where you really start losing confidence in shipping changes that don't subtly break things. But of course a huge codebase is hard to rewrite in general...
This isn't really true. Rust has a much better type system. When writing generic code the impact is enormous.
C++ doesn't have a real Empty Type, and it thinks Units have non-zero size. In practical terms this makes it incredibly wasteful and in terms of a clear abstraction it encourages you to come up with a hack that's unclear but efficient.
C++20 added `[[no_unique_address]]`, which lets a `std::is_empty` field alias another field, so long as there is only 1 field of that `is_empty` type.
https://godbolt.org/z/soczz4c76
That is, example 0 shows 8 bytes, for an `int` plus an empty field.
Example 1 shows two empty fields with the `int`, but only 4 bytes thanks to `[[no_unique_address]]`.
Example 2 unfortunately is back up to 8 bytes because we have two empty fields of the same type...
`[[no_unique_address]]` is far from perfect, and inherited the same limitations that inheriting from an empty base class had (which was the trick you had to use prior to C++20).
The "no more than 1 of the same type" limitation actually forced me to keep using CRTP instead of making use of "deducing this" after adopting c++23: a `static_assert` on object size failed, because an object grew larger once an inherited instance, plus an instance inherited by a field, no longer had different template types.
So, I agree that it is annoying and seems totally unnecessary, and has wasted my time; a heavy cost for a "feature" (empty objects having addresses) I have never wanted.
But, I still make a lot of use of empty objects in C++ without increasing the size of any of my non-empty objects.
C++20 concepts are nice for writing generic code, but (from what I have seen, not experienced) Rust traits look nice, too.
It's probably mean for me to say "empty type" to C++ people because of course just as std::move doesn't move likewise std::is_empty doesn't detect empty types. It can't because C++ doesn't have any.
You may need to sit down. An empty type has no values. Not one value, like the unit type which C++ makes a poor job of as you explain, but no values. None at all.
Because it has no values we will never be called upon to store one, we can't call functions which take one as a parameter, operations whose result is an empty type must diverge (ie control flow escapes, we never get to use the value because there isn't one). Code paths which are predicated on the value of an empty type are dead and can be pruned. And so on.
Rust uses this all over the place. C++ can't express it.
What is this empty type for? Could you provide an old man with a nice concrete example of this in action? I've used empty types in C++ to mark the end of recursive templates - which I used implement typelists before variadic templates were available.
But then you mention being unable to call functions which take an empty type as a parameter. At which point I cease to understand the purpose.
I don't know that I'll be able to convince you but I'll give a couple of examples.
What is the type of the expression "return x" ? Rust says that's ! pronounced Never, an empty type. This expression never had a value, control flow diverges.
So this means we can just use simple type arithmetic to decide that a branch which returns contributed nothing to the type of the expression - it has no possible value. This wasn't a special case, it's just type arithmetic.
Ok, lets introduce another. Rust has a suite of conversion traits. From, Into, TryFrom and TryInto. They're chained, so if I implement From<Goose> for Doodad, everybody gets the three other implied conversions. But the Try conversions are potentially fallible, hence the word Try. So they have an error type. Generic Code handling the Error type of potentially failing conversion will thus be written, even if in some cases the conversion undertaken chained back to my From<Goose> code. But wait, that conversation can't fail! Sure enough the chained TryFrom and TryInto produced will have the error type Infallible, which is an Empty Type.
So the compiler can trim all the error handling code, it depends upon this value which we know can't exist, therefore it never executes.
Which of course is equivalent to the statement "I have begun the process of understanding, but do not yet know what I do not know". My old High School teacher used to complain that I claimed understanding long before I actually reached it.
Anyway, thank you, and that seems a clever concept. I can't help but think that it's solving a problem that the language itself created - though that it doubtless an artifact of my as-yet limited understanding.
So "From" has to return something that might be an error, in some way. Just so that the Try... variants can be generated. And generic callers have to write something to handle that error - though presumably concrete callers do not because of the empty type.
> So "From" has to return something that might be an error, in some way. Just so that the Try... variants can be generated
Not quite. From can't fail, but TryFrom for example could fail.
Lets try a couple very concrete examples, From<u16> for i32 exists. Turning any 16-bit unsigned integer into a 32-bit signed integer works easily. As a result of the "chain" I mentioned, Rust will also accept TryInto<i32> for u16. This also can't fail - and it's going to run the identical code, but TryInto has an associated Error type, this must be filled out, it's filled out as Infallible. The compiler can see that Infallible is empty, therefore where somebody wrote error handling code for their TryInto<i32> if the actual type was u16 that Error type will be Infallible, therefore the code using it is dead.
Now, compare converting signed 16-bit integers to unsigned. This can clearly fail, -10 is a perfectly good signed 16-bit integer, but it's out of range for unsigned. So From<i16> for u16 does not exist. But TryInto<u16> for i16 does exist - but this type that really does have an error type, this conversion can and does fail with a "TryFromIntError" type apparently, which I expect has some diagnostics inside it.
void isn't a type. If you try to use it as a type you'll be told "incomplete type".
People who want void to be a type in C++ (proponents of "regular void") mostly want it to be a unit type. If they're really ambitious they want it to have zero size. Generally a few committee meetings will knock that out of them.
Can you instantiate an empty type? If yes, are all instances unique? Years ago, I was surprised to learn how C++ handles the (essentially) empty type (no data): A single byte to differentiate each instance.
Copying a bunch of stuff because the borrow checker won't let you share it can drag down performance as well. Yes, I do understand why one might conclude that tradeoff is worth it. But it is a tradeoff.
Funnily enough, because the borrow checker is so strict I feel more confident writing complex borrowing logic that I wouldn't dare attempting in C or C++ because even if I were to get everything right (a big if), there's no assurance that a later refactor wouldn't subtilty break the code. The borrow checker sometimes makes you copy data that you thought you didn't, but more often than not it is enforcing an actual edge case that would have been a bug, had the borrow checker not be present. If the copy is indeed so critical, you can also ease your pain with runtime checks instead using Rc/Arc, but that's another discussion.
If you're focused on just the theoretical correctness of the type system, go back to my first critique: C++ does not have Empty Types. So immediately a whole class of problems that are just a type system question in Rust are imponderable, you can't even say what you meant in C++
Sounds like a perfect situation for a strangler pattern? Wrap or transpile the original code into a language with stronger refactoring support and the rest should become incrementally easier.
The advantages of rust only come when you actually use the rust-provides abstraction, especially those around allocation and concurrency.
Even if transpiling is possible, the code would still not be structured I the rust way, and you wouldn't have any of the benefits. Same goes for wrapping.
This sort of property is nice to have in huge codebases where you really start losing confidence in shipping changes that don't subtly break things. But of course a huge codebase is hard to rewrite in general...