In search for a C++ style `const` qualifier

This is the outcome of a conversation we had at our company where there is big talk about adopting Rust. We have been using Go for a while, but one complaint ppls had about Go was the lack of a C++ style const qualifier. I am saying "C++ style" here because Rust's const is used in different situations.

It turns out that mut doesn't really mean "mutable" but more means "exclusive access". I think that also was mentioned either on this forum or r/rust, sorry for not having a reference at hand. In turn it means that "not mut" does not mean "not mutable" but means "shared access".

Example: A &Mutex<i32> can in fact mutate the wrapped i32 value by locking the Mutex first. This is safe since it manages safe mutations even though there might be other references. Same goes for atomics: AtomicI32::fetch_add takes &self. In contrast C++'s std::atomic<..>::fetch_add is not marked const (i.e. "might mutate").

so overall: "does not mutate the object" and "requires exclusive access" are two distinct concepts where Rust has only one and C++ has only the other. It is true that you don't need C++ const for safety, but it can still make it easy to enforce API invariants or just guide the user using an API away from mistakes.

Now my very meta questions:

  • Why is there no C++ style const in Rust?
  • Are there/were there plans for adding it?

For a couple of reasons:

  1. It's better from a correctness POV to have immutability by default, and have any potential sites of change be marked loudly. This is what mut achieves relative to a const.
  2. Given the focus on (and tools available for) unique/shared borrows, there isn't really a need for const in practice like there is in languages like C and Java.

I don't think there are any. There isn't really a need for it in Rust, since shared borrows do imply immutability in most cases.

7 Likes

What exactly are you looking for? If you only want the value (its memory representation) not not have interior mutability then there's the Freeze trait. It's unstable though.
But that doesn't cover external state reachable through &mut or global state. You'd need a pure effect marker (or absence of effects) on functions for that (not on types), which Rust currently does not have.

3 Likes

Thanks for the replies.

Agreed. I wouldn't mind having const-by-default and a "might mutate" qualifier (not mut though).

Except when it doesn't. I gues this probably includes all kinds of types that are "thread safe", i.e., allow mutability through shared references. As a random example I found a crate for a thread safe hash map where insert just accepts &self: chashmap::CHashMap - Rust. There is no way to pass this object around by reference with the guarantee that it won't be mutated.

That is an interesting trait, but not exactly what I am looking for. It would be nice to get a frozen representation from an object. For example AtomicI32 is !Freeze, but i still want to get a frozen view where interior mutability is taken away. In C++ this would be something like a casting to a const reference.

Point is, this fact is known by looking at the type only. Either it can be mutated always (by design), or it needs to be mutable explicitly, there's no third option.

Could you name some use case where simply accepting non-internally-mutable type (like &T instead of &Mutex<T> or i32 instead of AtomicI32) is not enough? I guess there could be some, but can't name one myself yet, and probably this is the most important part of discussion.

5 Likes

You didn't really answer my question about in-memory representation vs purity.

E.g. If we have

  • PinMutex - uses interior mutability to hold and lock a value
  • HeapMutex - contains a raw pointer to a heap object on which it then performs atomic operations. The struct itself doesn't have any interior mutability since it's just a pointer
  • StaticSingletonMutex - is a zero-sized type that does all its locking via global state. contains neither pointers nor interior mutability

which of those do you want to exclude and why not the others?

1 Like

BTW, const in C++ is not stronger than immutability in Rust. const *T only means you can't mutate it, but it doesn't stop others from mutating. This is because a cast from *T to const *T is allowed without blocking or invalidating the mutable pointer.

C++ also has a mutable keyword for members of const objects, which looks quite similar to Rust's interior mutability.

So I think you only see setters marked as mutable by convention, not language requirements, because that's easy and natural to do when it's not promising exclusive access.

21 Likes

And don't forget const_cast, which allows you to cast away constness as long as the underlying object is not a const object (casting away constness of a const object is UB, if I remember correctly, but casting away constness is otherwise fine).

12 Likes

Like C++, then? Consider the following function:

void bar(const Foo* foo) {
    foo->y++;
}

That's valid C++. And I have even used it to change constexpr variable!

Then… what exactly are you looking for? What exactly you mean by Why is there no C++ style const in Rust? ?

Because C++ const and Rust's default access are different, but internal mutability it entirely red herring. C++ has that, too.

Of courser C++ makes it possible to create such objects, too. Thus the questions becomes: what exactly are you looking for and what exactly don't you like about default shared access in Rust.

How exactly does it work? Let's go back to that same Foo type:

struct Foo {
    int x;
    mutable int y;
};

Both bar and baz function work fine:

void bar(const Foo* foo) {
    foo->y++;
}

void baz(const Foo& foo) {
    foo.y++;
}

And if they work then what exactly are you talking about? reinterpret_cast into entirely different type?

Tricky in Rust, but works more-or-less like in C++, too.

2 Likes

Indeed. You may pass const object via non-const pointer if your function doesn't actually modify it and you may have reference to const object that some else may modify.

In C++ const have totally insane rules which no one may follow, e.g. here is well-known correct code which Intel compiler miscompiles:

void replace(char *str, size_t len) {
    for (size_t i = 0; i < len; i++) {
        if (str[i] == '/') {
            str[i] = '_';
        }
    }
}

const char *global_str = "the quick brown fox jumps over the lazy dog";
…
replace(const_cast<char *>(str), std::strlen(str));
…

This gives un an answer to original question:

There are no C++ style const in Rust because C++ style const semantic is, frankly, insane, and not implementable.

What Rust offers may also be miscompiled, but at least rules make some sense and there's some [small?] hope of upholding them.

4 Likes

if you write code in Go, then you should think in Go, and design your APIs using go conventions.

same for rust: use rust idioms to write rust.

don't emulate one language using another language: you'll get the worst of both worlds.

I've seen C++ people writing Java and give every class a finalizer, which simply assign nulls to all the fields. they thought finalizers are the same as C++, and assigning null to a fields would "free" the memories. but this is the exact opposite you want to do in a language with GC that traces the object reachabilities. what might be a good practice in RAII languages like C++ turns into the worst nightmare for a managed language like Java.

the point being, if you are adopting a new language, adopt its idioms and paradigms, not just the syntax and libraries.

7 Likes

There is no way to pass this object around by reference with the guarantee that it won't be mutated.

Yeah, this statement is critical because I think a Rust user would not say this ... trying to put my finger on it ...

What you're asking for is a way for the caller to be sure a method won't change the data. But it turns out not to matter that much, if it's a thread-safe hash table. It's not like the caller can do anything useful with that guarantee. Other threads might change the data at any time!

If you really wanted that particular guarantee, you certainly could create a struct ReadHandle<'a>(&'a CHashMap); and expose only the non-mutating methods, and pass that around. It's not as trivial as adding const, it might be 30 lines of code. But here is the key bit: in Rust I have never in 10 years been remotely tempted to do this. I'm just not worried that a function is going to secretly call insert on the hash table. That is not the important work const has been doing for us in C++ and I don't miss it in Rust.

The most important work const does in C++ is identical to what shared references do in Rust: help you reason about the code, make it safe to hold references across function calls, because you know things aren't being invalidated. Rust's shared references are way better at that stuff, because the guarantees const can provide in C++ are pretty weak actually.

There are huge annoyances in Rust coming from C++ and it'll be rocky if you do decide to switch. But the lack of const is not something you should worry about. You're going to love it.

8 Likes

There are at least three different things it could mean for Mutex<T>:

  1. Block writers and get a read-only reference. You can do that by locking the mutex and passing &T. Can also use RwLock<T> to allow multiple readers at the same time.
  2. Get a read-only snapshot without blocking writers. You can do that by locking the mutex, cloning T, unlocking the mutex, and passing &T to the clone.
  3. Get a version of Mutex or RwLock that still synchronizes with writers but only has read-only locking capability. There is no direct support for that, but you could create a wrapper type around &RwLock that only has the read method and not the write method.
1 Like

To me clear to everybody who points out the oddities of const in C++: I definitely don't want to have all oddities of C++ const in Rust. const_cast is questionable, mutable is weird, but has some use cases (for example logically const objects that have things like a cache). But I wouldn't mind not having both of them or having them as unsafe.

Consider a toy function like:


pub fn has_odd_number_of_elements<K,V>(map: &chashmap::CHashMap<K,V>) -> bool {
  map.len() % 2 == 0
}

All functions on CHashMap are &self, because they work on shared references. so has_odd_number_of_elements could mutate map. But as a caller of has_odd_number_of_elements I would not expect this and it might break my code logically, maybe it would not break safety but the logic.

In contrast

bool has_odd_number_of_elements(const tbb::concurrent_hash_map<K,V>& map);

tells me that the function won't modify the map. Even more so, it forces the implementation to do so. Yes, it's C++ they could cast their way around it, but that's a concious choice and not a code bug then.

So const for me is not so much about writing safe programs (mut is good here), it's about communicating and enforcing an API contract.

1 Like

Given that the map is concurrent, you have to expect that any other user can modify the hash map at any time. So your code has to be resistant to such changes anyway, it can't break your logic.

10 Likes

The first Rust example with &chashmap tells you that the map will not be modified in the same way that the C++ example code does. In both cases, the method could modify the map but normally won't, because the method and the type aren't designed for that or documented to do that. So Rust and C++ are similar in this respect -- neither one has a way to absolutely guarantee via the type signature that the map won't be modified.

(In Rust APIs, things that do modify an object via a shared ref &T (called interior mutability) should be documented clearly to do so, but that's not what you're asking about.)

Roughly the same thing is true of interior mutability in Rust. In Rust you have to explicitly use an UnsafeCell to perform interior mutability.

3 Likes

If read-only is the goal, I'm struggling to understand why you would not just wrap chashmap::CHashMap with a new read-only newtype (CFrozenHashMap). After moving the chashmap::CHashMap it would only be reachable through the wrapper. That makes your intent crystal clear. I've done that. It works well. I assume the optimizer is capable of squashing the method calls; the run-time cost may be zero.

I'm not sure what you mean by this, exactly, because under a &const in C++ you can still mutate things marked mutable, just like you can mutate a &Cell<_> in Rust.

3 Likes

The point is that in C++ you normally achieve shared mutability via mutable references, whereas in Rust you normally achieve shared mutability via non-mut references with interior mutability. So it's different in practice. In C++ the mutable keyword is not normally used for that purpose. And so in C++ const& basically always means "please don't change this logically" but in Rust this doesn't work in the presence of shared mutability. Hence, one could imagine an extra feature in Rust that additionally indicates this in the presence of shared mutability.

3 Likes

Hmm... I know little about Go but as a long time user of C++ coming to Rust I find it a bit odd that those "ppls" are nitpicking about the const qualifier, whatever they think it should do.

Why? Because C++ has so many other ways to shoot yourself in the foot and const does not help much.

Sound's to me like they are fishing for a way to not have to use Rust.

At the end of the day C++ are different languages and sadly I don't know the diplomatic and enticing way of winning them over.

3 Likes