Rendered at 18:32:02 GMT+0000 (Coordinated Universal Time) with Cloudflare Workers.
tialaramex 2 days ago [-]
This has a section on Object Safety, I checked and the article was written this week, but "Object Safety" is a confusing name for this idea, and so for a little while now Rust calls this idea "dyn compatibility" because the most important thing you're getting if a trait is "dyn compatible" is that you can use "dyn Trait" - https://doc.rust-lang.org/1.98.1/reference/items/traits.html...
That link more comprehensively explains the rules too.
[Edit: Realized the end of the article explains this, the author began writing it months ago, likely before they read about the improved "dyn compatibility" naming]
loeg 2 days ago [-]
The aside about C++ seems a little confused too?
> C++ doesn’t have this problem at all, since virtual dispatch always goes through pointers and return types are always pointers too.
Uh. C++ can return objects by value. Maybe it isn't idiomatic. And I don't know if there's a convenient spelling like `Self`. But it runs into the same problem, of course -- you would need to know the concrete value type returned to have storage for it.
And Rust has the ~same solution as imagined for C++ here, I think? Have a `DynClone` trait that returns `Box<dyn Trait>` instead of `Self`.
neerajsi 2 days ago [-]
I agree with what you're saying. It's called: https://en.wikipedia.org/wiki/Object_slicing. Clearly a footgun in c++. You have to return a pointer to something that is not locally allocated.
gmueckl 2 days ago [-]
Polymorphism in C++ can only be correct on pointers and references (which are pointers internally).
Stack-allocated objects and polymorphism only combine if the object is initialized as its true type and a pointer or reference to it is handed out. Obviously, this can create object lifetime issues if the pointer or reference escapes the lifetime of the stack frame containing the object.
pjmlp 1 days ago [-]
Additionally concepts, and templates and compile time reflection.
There are a few ways to do polymorphism in C++, as per latest ratified standard.
quotemstr 2 days ago [-]
What does your explanation have to do with the fact that C++ can express by-value returns of complex objects?
mpyne 2 days ago [-]
Everything. The issue is that the compiler won't even bother with polymorphism through a vtable for a polymorphic type (one with a vtable), unless the object is accessed through a pointer or reference.
If you have a value of the type itself (not a pointer or reference), then polymorphism doesn't even enter the equation in C++, even if you initialize from a derived type.
E.g. in this code:
Base b(m_catalog.makeDerived());
b.call_virt_func();
Even if `call_virt_func` is declared virtual, it will be `Base::call_virt_func()` that is called here, guaranteed. From a language perspective, we already know that `b` is a `Base`, you literally declared and defined it that way.
Runtime polymorphism is therefore only a game for pointers or references; it is the process of resolving the indirection that even allows for polymorphism to become a thing in C++. But this means that the compiler cannot know the actual type at compile-time for a polymorphic type, unless it can perform devirtualization as an optimization pass.
So although C++ will certainly allow you to define a class method that returns a virtual type by value (and not by pointer or reference), even for complex types, it is almost certainly a bug to do this unless you know for sure what the type will be statically, at compile time. Because the object you create as the return value will be forced to be the return type declared at compile time, "forgetting" the fact that it was created from a type deeper in the inheritance chain. This is the 'slicing problem' that was mentioned in the earlier comment.
quotemstr 1 days ago [-]
Ah. Dyno (https://github.com/ldionne/dyno) is good at this stuff. If you have types Base, Child1, Child2, you can just return a Dyno object that can be any of these, expressed as a tagged union and not a Box-equivalent, and then do regular vtable-based or otherwise polymorphic dispatch into the object. You can also arrange it so that if you have a Child3 that can't fit in the (Base, Child1, Child2) union, the Child3 can be heap-allocated and invoked transparently as well. It's open-world type erasure.
C++ is so freakishly powerful is that it can not only solve this problem, but it can solve it via a regular library and not a language extension.
gmueckl 1 days ago [-]
Dyno is really freakish and I can see a couple of pain points if someone tries to use it productively. A IMO glaring one is that dyno::poly is a boxing type that will happily take your value type and shove it on the heap while nobody's looking. This is a defect similar to the silent heap allocations for captured parameters in std::function.
On the other hand, it's really impressive that one can push dynamic function pointer lookup tables in C++ to a point where it looks almost, but maybe not quite like the real thing.
quotemstr 1 days ago [-]
It is the real thing. Not "almost". The actual thing. If you want to forestall accidental heap policy, make a storage policy that prohibits it.
evmar 2 days ago [-]
In my own journey of discovery I found https://cheats.rs/ very helpful, and in particular its "memory layout" section has visualizations. (No affiliation with the site, just a happy reader!)
Panzerschrek 2 days ago [-]
I once faced a tricky bug involving fat pointers (containing virtual tables) in Rust. Two such pointers may be distinct, even if they reference to the same object, because (for some reason) the compiler may create two (or even more) copies of the virtual functions table and use them in different places.
SkiFire13 1 days ago [-]
> for some reason
Just to expand on this: which vtables will be needed is not known before running codegen. At that point however multiple codegen units might generate the same vtable, and that might not get deduplicated by a later pass.
Moreover with dynamic linking (especially on Windows) it's impossible to guarantee that such vtables will be unique.
imtringued 1 days ago [-]
Rust has super traits but does not support subtyping. The way super traits work is that they merge all the vtables into one big vtable. However, you can still have a direct dyn trait reference to the sub traits.
This means you need a separate vtable for all super traits and individual traits.
Panzerschrek 2 days ago [-]
> A trait must follow so-called object safety rules to be used as a trait object
This seems for me to be a major design flaw of Rust. It tries to repurpose traits for dynamic polymorphism, even if this doesn't fit perfectly. C++ is more honest, it has two separate mechanisms for static polymorphism (templates) and dynamic polymorphism (inheritance).
gignico 2 days ago [-]
In the contrary it’s one of the best design decisions of the language. The rules for object safety are the same C++ applies to virtual functions. On the other hand you don’t have to choose a priori whether you’ll need dynamic or static polymorphism, you can choose at use site, and the size of the objects do not pay for dynamic dispatch because the vtable pointer is kept together with the object pointer, not within the object.
Panzerschrek 2 days ago [-]
> you can choose at use site
This is possible in C++ too, but it may require extra work. But is it really needed that often to use both kinds of polymorphism for the same type?
> the size of the objects do not pay for dynamic dispatch because the vtable pointer is kept together with the object pointer, not within the object.
You have identical memory overhead in Rust and C++ if you store a single pointer to a polymorphic object. But if more than one such pointer is stored (if it's shared), Rust uses more memory, since it stores N virtual table pointers (together with each object pointer), where C++ stores exactly one virtual table pointer within the object itself.
lightingthedark 2 hours ago [-]
There is another important reason not to store the vtable pointer in the object: performance! In C++ if you want to make a virtual method call you have a double indirection where you must first load the object and only then can you load the vtable. Having a fat pointer like Rust does means that both loads can be in flight at once. If you are using enough dyn references to care about the extra memory then you'll likely care about the performance impact of those extra indirections even more.
SkiFire13 1 days ago [-]
> This is possible in C++ too, but it may require extra work.
Everything is possible, what matters is how much work you have to do to achieve and maintain it.
> You have identical memory overhead in Rust and C++ if you store a single pointer to a polymorphic object. But if more than one such pointer is stored (if it's shared), Rust uses more memory, since it stores N virtual table pointers (together with each object pointer), where C++ stores exactly one virtual table pointer within the object itself.
On the other hand Rust uses less memory if you store 0 polymorphic pointers, while C++ still pays the cost of storing a vtable pointer for each object instance.
Effectively C++ choose a design that optimizes for having a lot of polymorphic pointers, while Rust optimized for supporting polymorphism for all objects at no extra cost if you don't use it.
tialaramex 1 days ago [-]
The C++ design is optimised for the case where you're mostly storing Things everywhere and then at runtime code works out whether each particular Thing is a Customer, or a Product, or a Target, or an Artist, or what...
I don't think even an LLM writes software like this, maybe somebody's Java 101 class teaches this, but frankly I think that's a bad way to teach even Java.
The Rust approach optimises for cases where Customers and Products and Targets and Artists are stored and treated separately and if we do need the generic Thing somewhere it's pretty rare so we store the extra information only where needed.
mrkeen 1 days ago [-]
I'm not a C++er, but I sorely miss this is nearly every non-Haskell language.
I have a type capable of some behavior, e.g ToString. I also want to write general code operating on such objects (highlighting, obfuscating passwords, escaping, etc.).
But I have zero desire to throw away its static type information. If it gets stored in an Escaped<T>, the compiler ought to know T is still my type.
imtringued 1 days ago [-]
I don't see how the first rule is a major design flaw.
The second rule is mildly annoying but all it means is that dyn compatible traits must use dyn compatible traits in their function signatures so you have to duplicate it for the compile time dispatch and the runtime dispatch.
Sjonny 1 days ago [-]
This was a great read. We need more books like rust for c++, or rust for Java coders. You already have a good grasp of programming, but learning things like this is never well explained in books that try to learn you programming from scratch in their new language. Those books are too introductionary and therefore quite tedious.
tialaramex 1 days ago [-]
One problem in trying to teach a "conversion" type approach is that if you didn't really understand a thing in language A where it was maybe not too important, what do we tell you in language B where it's identical (or even mostly identical) but now crucial ?
If we teach you the important thing, language A people who did know wonder why we're wasting their time on a concept they knew, but if we do not teach it, anybody who got by in language A without this understanding is now screwed in B where it's very important.
returningfory2 2 days ago [-]
Very nice. As a follow up would be interesting to also reverse engineer the structure of the vtable itself. I guess it’s a list of pointers to the method implementations?
My first question was, if the vtable only has a single function in it (as in these examples), can the compiler optimize it to a function pointer to save an indirection?
SkiFire13 1 days ago [-]
The vtable contains at least the following informations:
- the size of the pointed type
- the alignment of the pointed type
- a function pointer for its drop glue
That's already quite big to inline to save on indirection, and this is before adding more methods.
ketzu 2 days ago [-]
> In Rust, that question is answered by the borrow-checker at compile time:
(About zero sized objects being the same)
But why does that mean the programmer never has to check? (or if they want that information from the borrow checker how would they get it?) It's not motivated as the intro above for c++ was just "In C++, we might do this to check if two pointers refer to the same object".
So the borrow checker knows already, why does that stop the programmer from wanting to know or separate these cases?
steveklabnik 2 days ago [-]
> if they want that information from the borrow checker how would they get it?
Rust just defaults to value equality over reference equality. This is true for everything, not just ZSTs.
(I find the post's framing of "it's stored in the borrow checker" to be a bit odd, but I can't put my finger on exactly what it is. The borrow checker doesn't determine these sorts of semantics, it checks for liveliness and aliasing, so "do these pointers alias" isn't inherently not the borrow checker's job, it just strikes me as an odd way to put it. Maybe it's because you don't "ask the borrow checker for that information" really.)
ketzu 2 days ago [-]
As the article itself discusses, pointers to zero sized objects are not necessarily different (they write it is only the case in debug mode).
> I find the post's framing of "it's stored in the borrow checker" to be a bit odd
That's exactly what I wanted to say as well.
I feel like it would have been better to just skip the borrow checker mention and just go "in rust this can not be done reliably ..(section about pointers being the same)"
AnyTimeTraveler 1 days ago [-]
This can be done reliably, though.
You can just do
if a == b {
...
Just that the check will be replaced at compiletime with a constant, since the borrow-checker tracks all objects lifetimes and can use that information to optimize the check away.
The objects themselves don't make it into the compiled binary, since they have no size, but all the required information about them will make it in. So you can treat them like ordinary objects and do all the usual operations on it, without wasting any memory during runtime.
tialaramex 1 days ago [-]
In general our types won't be comparable, suppose we've got three zero size types Truth, Beauty and Strange and we make six variables a and b have type Truth, c and d are Beauty, e and f are Strange:
Out of the box a == b will not compile, for the same reason that in most languages you can't divide the string "This" by the string "That" you cannot use this operator here because it's nonsense unless somebody defines what it means.
We can define an implementation for this operator on Truth, but, it has nothing more to go on than what we already knew - remember these are zero size types so they do not have properties we could investigate, we can say they're always equal, in which case a == a is now true too, or indeed that they're never equal, in which case a == a is now false - they don't have identity, we can't tell them apart.
We're allowed to write implementations for comparisons to other types, so we could say you can compare a Truth to a Beauty, and a Beauty to a Strange, but you can't compare a Strange to a Truth for example, so then a == c would compile and so would c == f but a == f would not compile.
Yes, if the types are known none of this results in any actual operations at runtime because it'll get optimised out.
2 days ago [-]
TazeTSchnitzel 2 days ago [-]
I'm not sure if this quite answers your question, but the difference in philosophy here is that C++ objects with pointers always have identity, whereas a Rust object only has identity if it has a non-zero size. It's an application of the zero-overhead principle, "you only pay for what you use".
phire 2 days ago [-]
> whereas a Rust object only has identity if it has a non-zero size.
Rust also has the complication that function pointers are not guaranteed to have an unique identity; If multiple functions compile to the same code, the compiler is allowed de-duplicate them.
The documentation [0] also warns it's also possible for the compiler to create multiple versions of the same function. And while I've absolutely seen the compiler to create multiple optimised versions of functions in disassembled code (partial inlining based on the caller), I'm not sure it's possible to get pointers to more than one version.
"zero sized objects don't have identity" actually would answer my question and is a really interesting factoid, thanks!
tialaramex 2 days ago [-]
This is why C++ doesn't have ZSTs, it wants all objects to have identities, the obvious way to distinguish them is by where they are in memory, but ZSTs don't have distinct addresses in memory.
ameliaquining 2 days ago [-]
I'm not a C++ expert. Why does C++ want all objects to have identities? Presumably it has some feature or something Rust doesn't have that requires this?
mpyne 2 days ago [-]
It's pretty deeply tied into C++'s object lifetime mechanics, which rely on storage being available and reserved for the use of that object's lifetime. If multiple objects with valid lifetimes had a situation where one lifetime needs to end, what should happen to the other objects' lifetimes?
C++ actually did end up evolving the ability to define a zero-sized class without a unique memory address, but mostly to allow optimizations like the empty base optimization to apply in other situations where it could make sense, especially with templated or constexpr code.
tialaramex 1 days ago [-]
> C++ actually did end up evolving the ability to define a zero-sized class without a unique memory address
Did it? Are you talking about the no_unique_address attribute (I had to go look that up because WG21 apparently doesn't care about consistently using or not using separators in attribute names) ? That attribute lets you do the same trick as empty base class but without the ceremony, however it doesn't let you make ZSTs.
mpyne 1 days ago [-]
Yes, that's what I'm talking about. It does expand on the empty base object optimization, though it is still not fully generic.
But you can now have multiple zero-sized types as siblings in a struct or class that can be zero-sized, the restriction is that they do have to be different types.
tialaramex 1 days ago [-]
But that's just not ZSTs. All C++ is doing is, as with EBC you can overlap a thing which doesn't need any representation with any number of other such things and with the no_unique_address attribute C++ will say their total size is 1.
C++ is bad at type arithmetic, that's nothing new. Rust has unit types like () which have size zero, and it has empty types like ! [aka never] which do not have a size because no values of these types exist. C++ struggles with this, if you attempt a unit type you get a type with a single byte that's all padding, thus size 1, and you can't write an empty type at all.
2 days ago [-]
wrs 2 days ago [-]
I've tried (admittedly for just a few minutes) to come up with a case where you'd need to compare two pointers to ZSTs in any real algorithm, and failed. Can you think of one?
phire 2 days ago [-]
I suspect it's actually possible to prove such an algorithm can't exist.
By definition, a ZST hold no runtime data. It does hold some compile-time data based on its existence, but after compiling, that has been type erased away.
Since a ZST holds zero bits of state, there can only be one valid instance of it. You can't have multiple different versions of the same ZST representing different things.
So if you have one, you automatically know it's going to be equal to all other instances of the same ZST type. And not equal to any other ZSTs. There is no point doing a pointer comparison, as that gives you no extra information, type ownership is enough.
DonHopkins 1 days ago [-]
Donald Rumsfeld forgot the zero-sized unknown: you know its type, you know its complete value, but you don't know which instance it is.
COM would ask the object for its canonical IUnknown pointer. Rust answers that the question has been optimized away.
A ZST has one possible value, not one possible occurrence. That doesn't prove that no identity-dependent algorithm can exist; it proves that a ZST cannot carry the identity such an algorithm requires. If identity matters, it must be represented somewhere -- and then it is no longer zero-overhead.
COM is essentially a language-neutral ABI built from C++-style vtables, and QueryInterface is essentially a type-safe dynamic_cast.
In Rumsfeldian terms, IUnknown provides a known way to interrogate an otherwise unknown object. QueryInterface resolves known unknowns by asking about specific interface IDs; it cannot ask about unknown unknowns.
> COM is essentially a formal way of using C++ vtables [1] from C and other languages, so you can create and consume components in any language, and call back and forth between them. It's a way of expressing a rational subset of how C++ classes work and format in memory, in a way that can be implemented in other languages.
> It was the outcome of the C / C++ / Visual Basic language wars at Microsoft.
dwattttt 1 days ago [-]
> you know its type, you know its complete value, but you don't know which instance it is.
Is it an incoherent view to consider there to only be one instance? I understand Rust considers () its "unit type" a ZST.
It's not obvious to me that the borrow checker actually knows, so much as it can prove there are no illegal conflicts between mut and non-mut references to the same object.
If you somehow need this property in your programs, I think you can just add a 1-byte member and use pointer equality. (I'm not a Rust expert.)
wahern 2 days ago [-]
I assumed it's a figure of speech, but, yeah, the borrow checker only enforces function-local invariants, with no interprocedural analysis or memorization. Parameter aliasing is just a logical deduction from assuming local invariants are maintained at all call sites. That's why the borrow checker imposes very minimal cost (esp when it was block based), and doesn't even require static compilation. Traits and other aspects of the type system are where the complexity and slow compile times come from.
I guess maybe the semantics of ZSTs complicate the aliasing story a tad?
Waterluvian 2 days ago [-]
I never did well in English and I usually don’t know it when I see it. But it was immediately evident that the writing style of this blog was sparking joy. Even the intro section was just so well structured that I had to read it again.
Surac 1 days ago [-]
[flagged]
7e 2 days ago [-]
The title includes the word “visualizing” but there is not a single diagram in this article. Is English not the first language of the author?
That link more comprehensively explains the rules too.
[Edit: Realized the end of the article explains this, the author began writing it months ago, likely before they read about the improved "dyn compatibility" naming]
> C++ doesn’t have this problem at all, since virtual dispatch always goes through pointers and return types are always pointers too.
Uh. C++ can return objects by value. Maybe it isn't idiomatic. And I don't know if there's a convenient spelling like `Self`. But it runs into the same problem, of course -- you would need to know the concrete value type returned to have storage for it.
And Rust has the ~same solution as imagined for C++ here, I think? Have a `DynClone` trait that returns `Box<dyn Trait>` instead of `Self`.
Stack-allocated objects and polymorphism only combine if the object is initialized as its true type and a pointer or reference to it is handed out. Obviously, this can create object lifetime issues if the pointer or reference escapes the lifetime of the stack frame containing the object.
There are a few ways to do polymorphism in C++, as per latest ratified standard.
If you have a value of the type itself (not a pointer or reference), then polymorphism doesn't even enter the equation in C++, even if you initialize from a derived type.
E.g. in this code:
Even if `call_virt_func` is declared virtual, it will be `Base::call_virt_func()` that is called here, guaranteed. From a language perspective, we already know that `b` is a `Base`, you literally declared and defined it that way.Runtime polymorphism is therefore only a game for pointers or references; it is the process of resolving the indirection that even allows for polymorphism to become a thing in C++. But this means that the compiler cannot know the actual type at compile-time for a polymorphic type, unless it can perform devirtualization as an optimization pass.
So although C++ will certainly allow you to define a class method that returns a virtual type by value (and not by pointer or reference), even for complex types, it is almost certainly a bug to do this unless you know for sure what the type will be statically, at compile time. Because the object you create as the return value will be forced to be the return type declared at compile time, "forgetting" the fact that it was created from a type deeper in the inheritance chain. This is the 'slicing problem' that was mentioned in the earlier comment.
C++ is so freakishly powerful is that it can not only solve this problem, but it can solve it via a regular library and not a language extension.
On the other hand, it's really impressive that one can push dynamic function pointer lookup tables in C++ to a point where it looks almost, but maybe not quite like the real thing.
Just to expand on this: which vtables will be needed is not known before running codegen. At that point however multiple codegen units might generate the same vtable, and that might not get deduplicated by a later pass.
Moreover with dynamic linking (especially on Windows) it's impossible to guarantee that such vtables will be unique.
This means you need a separate vtable for all super traits and individual traits.
This seems for me to be a major design flaw of Rust. It tries to repurpose traits for dynamic polymorphism, even if this doesn't fit perfectly. C++ is more honest, it has two separate mechanisms for static polymorphism (templates) and dynamic polymorphism (inheritance).
This is possible in C++ too, but it may require extra work. But is it really needed that often to use both kinds of polymorphism for the same type?
> the size of the objects do not pay for dynamic dispatch because the vtable pointer is kept together with the object pointer, not within the object.
You have identical memory overhead in Rust and C++ if you store a single pointer to a polymorphic object. But if more than one such pointer is stored (if it's shared), Rust uses more memory, since it stores N virtual table pointers (together with each object pointer), where C++ stores exactly one virtual table pointer within the object itself.
Everything is possible, what matters is how much work you have to do to achieve and maintain it.
> You have identical memory overhead in Rust and C++ if you store a single pointer to a polymorphic object. But if more than one such pointer is stored (if it's shared), Rust uses more memory, since it stores N virtual table pointers (together with each object pointer), where C++ stores exactly one virtual table pointer within the object itself.
On the other hand Rust uses less memory if you store 0 polymorphic pointers, while C++ still pays the cost of storing a vtable pointer for each object instance.
Effectively C++ choose a design that optimizes for having a lot of polymorphic pointers, while Rust optimized for supporting polymorphism for all objects at no extra cost if you don't use it.
I don't think even an LLM writes software like this, maybe somebody's Java 101 class teaches this, but frankly I think that's a bad way to teach even Java.
The Rust approach optimises for cases where Customers and Products and Targets and Artists are stored and treated separately and if we do need the generic Thing somewhere it's pretty rare so we store the extra information only where needed.
I have a type capable of some behavior, e.g ToString. I also want to write general code operating on such objects (highlighting, obfuscating passwords, escaping, etc.).
But I have zero desire to throw away its static type information. If it gets stored in an Escaped<T>, the compiler ought to know T is still my type.
The second rule is mildly annoying but all it means is that dyn compatible traits must use dyn compatible traits in their function signatures so you have to duplicate it for the compile time dispatch and the runtime dispatch.
If we teach you the important thing, language A people who did know wonder why we're wasting their time on a concept they knew, but if we do not teach it, anybody who got by in language A without this understanding is now screwed in B where it's very important.
- the size of the pointed type - the alignment of the pointed type - a function pointer for its drop glue
That's already quite big to inline to save on indirection, and this is before adding more methods.
(About zero sized objects being the same)
But why does that mean the programmer never has to check? (or if they want that information from the borrow checker how would they get it?) It's not motivated as the intro above for c++ was just "In C++, we might do this to check if two pointers refer to the same object".
So the borrow checker knows already, why does that stop the programmer from wanting to know or separate these cases?
If you want to compare if two pointers point to the same place, you use https://doc.rust-lang.org/stable/std/ptr/fn.eq.html
Rust just defaults to value equality over reference equality. This is true for everything, not just ZSTs.
(I find the post's framing of "it's stored in the borrow checker" to be a bit odd, but I can't put my finger on exactly what it is. The borrow checker doesn't determine these sorts of semantics, it checks for liveliness and aliasing, so "do these pointers alias" isn't inherently not the borrow checker's job, it just strikes me as an odd way to put it. Maybe it's because you don't "ask the borrow checker for that information" really.)
> I find the post's framing of "it's stored in the borrow checker" to be a bit odd
That's exactly what I wanted to say as well.
I feel like it would have been better to just skip the borrow checker mention and just go "in rust this can not be done reliably ..(section about pointers being the same)"
if a == b { ...
Just that the check will be replaced at compiletime with a constant, since the borrow-checker tracks all objects lifetimes and can use that information to optimize the check away.
The objects themselves don't make it into the compiled binary, since they have no size, but all the required information about them will make it in. So you can treat them like ordinary objects and do all the usual operations on it, without wasting any memory during runtime.
Out of the box a == b will not compile, for the same reason that in most languages you can't divide the string "This" by the string "That" you cannot use this operator here because it's nonsense unless somebody defines what it means.
We can define an implementation for this operator on Truth, but, it has nothing more to go on than what we already knew - remember these are zero size types so they do not have properties we could investigate, we can say they're always equal, in which case a == a is now true too, or indeed that they're never equal, in which case a == a is now false - they don't have identity, we can't tell them apart.
We're allowed to write implementations for comparisons to other types, so we could say you can compare a Truth to a Beauty, and a Beauty to a Strange, but you can't compare a Strange to a Truth for example, so then a == c would compile and so would c == f but a == f would not compile.
Yes, if the types are known none of this results in any actual operations at runtime because it'll get optimised out.
Rust also has the complication that function pointers are not guaranteed to have an unique identity; If multiple functions compile to the same code, the compiler is allowed de-duplicate them.
The documentation [0] also warns it's also possible for the compiler to create multiple versions of the same function. And while I've absolutely seen the compiler to create multiple optimised versions of functions in disassembled code (partial inlining based on the caller), I'm not sure it's possible to get pointers to more than one version.
[0] https://doc.rust-lang.org/std/ptr/fn.fn_addr_eq.html
C++ actually did end up evolving the ability to define a zero-sized class without a unique memory address, but mostly to allow optimizations like the empty base optimization to apply in other situations where it could make sense, especially with templated or constexpr code.
Did it? Are you talking about the no_unique_address attribute (I had to go look that up because WG21 apparently doesn't care about consistently using or not using separators in attribute names) ? That attribute lets you do the same trick as empty base class but without the ceremony, however it doesn't let you make ZSTs.
But you can now have multiple zero-sized types as siblings in a struct or class that can be zero-sized, the restriction is that they do have to be different types.
C++ is bad at type arithmetic, that's nothing new. Rust has unit types like () which have size zero, and it has empty types like ! [aka never] which do not have a size because no values of these types exist. C++ struggles with this, if you attempt a unit type you get a type with a single byte that's all padding, thus size 1, and you can't write an empty type at all.
By definition, a ZST hold no runtime data. It does hold some compile-time data based on its existence, but after compiling, that has been type erased away.
Since a ZST holds zero bits of state, there can only be one valid instance of it. You can't have multiple different versions of the same ZST representing different things.
So if you have one, you automatically know it's going to be equal to all other instances of the same ZST type. And not equal to any other ZSTs. There is no point doing a pointer comparison, as that gives you no extra information, type ownership is enough.
https://en.wikipedia.org/wiki/There_are_unknown_unknowns
COM would ask the object for its canonical IUnknown pointer. Rust answers that the question has been optimized away.
A ZST has one possible value, not one possible occurrence. That doesn't prove that no identity-dependent algorithm can exist; it proves that a ZST cannot carry the identity such an algorithm requires. If identity matters, it must be represented somewhere -- and then it is no longer zero-overhead.
COM is essentially a language-neutral ABI built from C++-style vtables, and QueryInterface is essentially a type-safe dynamic_cast.
In Rumsfeldian terms, IUnknown provides a known way to interrogate an otherwise unknown object. QueryInterface resolves known unknowns by asking about specific interface IDs; it cannot ask about unknown unknowns.
https://news.ycombinator.com/item?id=12975257
> COM is essentially a formal way of using C++ vtables [1] from C and other languages, so you can create and consume components in any language, and call back and forth between them. It's a way of expressing a rational subset of how C++ classes work and format in memory, in a way that can be implemented in other languages.
> It was the outcome of the C / C++ / Visual Basic language wars at Microsoft.
Is it an incoherent view to consider there to only be one instance? I understand Rust considers () its "unit type" a ZST.
Reading https://en.wikipedia.org/wiki/Unit_type, your question "which instance is it" seems equivalent to asking Python "but which None instance is it?"
If you somehow need this property in your programs, I think you can just add a 1-byte member and use pointer equality. (I'm not a Rust expert.)
I guess maybe the semantics of ZSTs complicate the aliasing story a tad?