Programming

Why Const Sucks (jmdavisprog.com)

steloflute 2023. 8. 11. 23:58

Why Const Sucks (jmdavisprog.com)

 

Why Const Sucks

Why Const Sucks As could easily be guessed by the title, this article discusses the issues I have with const - primarily with an eye towards D but also with regards to C++. My view of const has changed considerably over the years, and this talks about that

jmdavisprog.com

Jonathan M Davis: The Long-Winded D Guy
Why Const Sucks

As could easily be guessed by the title, this article discusses the issues I have with const - primarily with an eye towards D but also with regards to C++. My view of const has changed considerably over the years, and this talks about that. In fact, the situation with const is part of what led me to D.

A Bit of Background

I originally come from a C++ background. It's what I used on my own before programming in school and what I originally used in high school and college. I read books like Effective C++ and C++ Gotchas about good and bad practices (I've always been a bit surprised that many programmers don't think that the correct thing to do when learning a language is to read a bunch of books about it to learn it through and through), and one of the things that I definitely bought into was const-correctness - that is, the idea that any function that is "logically" const should be marked as const and that as much as possible should be const. As such, when I later ended up using Java for school and work, I found it incredibly frustrating that Java didn't have const.

What Java has instead is final, which IMHO is borderline useless. C++'s const allows any portion of a type to be marked as const such that it's easy to have head-const or tail-const or some combination thereof, whereas Java's final is only head-const. That means that a reference to an object can be made const but not the object itself. It's equivalent to T* const in C++ - that is, the pointer is const but the object itself isn't, whereas what most C++ code does is either make the entire type const or make the object const while leaving the pointer mutable - const T*. So, what's usually used in C++ is tail-const. Occasionally, head-const makes sense, but in my experience, it's almost always useless. tail-const allows for data to be protected against mutation, whereas head-const just protects against accidentally changing what data the pointer points to, and that's rarely useful. So, Java is basically neutered with regards to const, and as a C++ programmer, that was quite frustrating.

In fact, that's just one of the many examples of what was incredibly frustrating for me when programming in Java. After programming in C++, in many respects, programming in Java seemed like being stuck in the stone age. Java just stripped out way too many powerful features in comparison. In fact, Java stripped out so much that it's not even possible to write a swap function in it. So, for me, programming in Java felt like programming with a straitjacket - a very verbose straitjacket (if straitjackets could somehow be verbose anyway).

Now, Java also added a number of nice things that C++ didn't have - such as an array knowing its own length, array bounds checking, and other features geared towards protecting the programmer from stupid mistakes. So, while programming in Java was extremely frustrating, it also made programming in C++ more frustrating, because C++ lacks the various safety features that Java added. So, I was stuck on the one hand with Java, which was so primitive, it wasn't even funny (in fact, one of my professors said that Java wasn't a real language until they added generics, since then it actually had some complexity), and on the other hand, I had C++, which was wonderfully powerful but made it far, far too easy to blow my foot off. So, for a while, I actually considered creating my own language (which in retrospect, seems completely stupid given the amount of effort that would have involved), but ultimately it led me to looking at other languages and finding D.

IIRC, at the time I started messing around with D, dmd 2.017 was the latest D2 compiler, and it had const, whereas my understanding at the time was that D1 did not have const (I've since learned that D1 had the const keyword and used it for some form of const but not for what D2 or C++ used it for, and the addition of D2's const was the main reason why D2 was forked off of D1 in the first place - implementing it was going to break too much code). So, because I wanted const, I went with D2, which in some ways was probably dumb, since at that point, D2 was very much in its infancy and broke compatability all the time, but for better or worse, I went with D2 and thus have never done much with D1. Regardless, const was very important to me and had a significant impact on my choice of language at the time. I don't know if I would have ended up in the land of D if it weren't for const, and I don't know that I would have ended up going with D if D2 didn't have a const that was similar to C++'s const.

const and C++

Back when I first found D, if I were asked what the benefits of const were in C++, I probably would have said something like

  • const protects objects from being mutated.
  • const tells the programmer which functions mutate an object and which don't.
  • const allows for the compiler to better optimize code based on the fact that it knows that an object hasn't been mutated.

And these are all things that seem to be true on the surface, but as I've learned more, I now know that these really aren't true - or at least are true under much more restrictive circumstances than would first seem to be the case. For instance, at first glance, this member function

int foo() const;

would appear to guarantee that it does not mutate any member variables. However, no such guarantee actually exists. C++ allows for const to be cast away more or less with impunity (as I understand it, the one exception is that it's undefined behavior to do so if a variable was directly constructed as const). And that means that foo's implementation could be something like

int MyClass::foo() const
{
    SomeType* st = const_cast<SomeType*>(_memVar);
    st->bar = 7;
    return 42;
}

and that's perfectly legal. Similarly, a member variable could be marked with the mutable keyword, and then the cast isn't even necessary. The compiler then simply lets the code mutate the variable as if it weren't const at all. At the extreme, if all member variables were marked with mutable, then the const on the member function would be meaningless. Also, because C++ has no form of pure, there's no guarantee that the function doesn't access global state, which means that in principle, even if neither casts nor mutable were used, the object's state could actually live outside of the object itself and be freely mutated even though the function is const, because all const on a member function does is make the implicit this pointer const. All of this means that const actually provides zero guarantees about an object not being mutated (and even in the case where the object is directly constructed as const, that just means that it's undefined behavior when the object is mutated, not that the compiler actually protects against it being mutated).

Now, there is effectively a social contract that C++ programmers expect when const is used. const is supposed to indicate that the type is "logically" const - that is, from the outside, the state of the object does not appear to change. So, it can change during a function call so long as it's set back before the function returns, and stuff like mutexes can freely be mutated, because they're not conceptually part of the object's state. So, as long as all of the programmers involved use const in that manner, a programmer can reasonably rely on the fact that an object that's const won't have its logical state mutated. However, there's no guarantee that const is being used in that manner, and the compiler certainly can't verify that it is, meaning that if any programmer involved makes a mistake, a const object could have its logical state mutated, invalidating the assumptions that go with const, and no one would have a clue. So, arguably, at that point, const is just documentation. It does prevent accidental mutation, since if no casts or mutable are involved, and the object's state is actually inside the object, then as long as the object is const, the compiler will give an error when the code attempts to mutate the object. But with all of the backdoors that C++ provides, it doesn't actually provide any real guarantees.

One side effect of this is that it's actually fairly difficult for the compiler to optimize based on const. For it to do so, it needs actual guarantees about an object not being mutated, which const provides very little of - e.g. unless the compiler is looking at a function's implementation when it's called (which compilers usually don't do except during inlining), then the fact that the object is marked as const in the function's signature is meaningless and can't be used for optimizations, because there's no guarantee that the function didn't mutate the object. I'm not a compiler expert, so I don't know exactly how often a typical C++ compiler is able to optimize based on const, but clearly, outside of simple cases or where it's done a lot of inlining, the compiler can't assume that an object hasn't been mutated, so it can't optimize based on const.

Another issue with C++'s const is that it's not transitive. This means that even if casting away const weren't legal and mutable weren't a thing, code that does something like have a member variable of type vector<T*> will have an object where only have part of its state is protected against mutation. In a const member function, the vector would be typed as const vector<T*>, which means that no elements can be added to or removed from the vector, but the elements themselves aren't protected against mutation and thus could be mutated with impunity, meaning that a great deal of the object's state could be mutated even though the function is const. So, even without the backdoors, C++'s const does a poor job of protecting an object from mutation.

So, given all of this, if I were asked right now what the benefits of C++'s const are, I would say something more like

  • const serves as documentation that the programmer intends for an object to be treated as logically const.
  • const protects against the accidental mutation of objects.

but I would say nothing about compiler guarantees or optimizations.

I still use const heavily in my C++ code, because I like the fact that the programmer's intent is documented (and honestly, because after all these years of using it, it just feels wrong not to). And in some cases, it does catch mistakes where the programmer accidentally ends up mutating an object. However, I can think of exactly one case in my entire career where const actually prevented a bug, and that's when I got the arguments to the STL's copy function backwards. Because I was using const, the compiler screamed at me, because it was being told to copy a mutable iterator into a const one. And that was useful. If I hadn't been using const, I would have been copying in entirely the wrong direction, which may or may not have been quickly found when testing. But that's the only case I remember where the compiler protected me because of const. It wouldn't surprise me if there were more that I don't remember, but it's quite clear to me that in spite of the fact that I use const all over the place, it rarely actually results in the compiler finding bugs for me. And that being the case, it really does feel like C++'s const isn't good for much more than documentation in spite of the fact that I use it heavily. And if it isn't really doing much more than documenting that an object isn't supposed to be mutated, is C++'s situation really much better than Java's as far as const goes? Especially if one considers the fact that it can give a false sense of security about an object not being mutated?

const and D

D attempts to fix the mistakes of C++'s const, though whether the result is better or worse is a matter of some debate.

Unlike C++'s const, D's const actually provides real compiler guarantees. It has no backdoors. Casting away const and mutating the result is undefined behavior, and D has no mutable keyword or anything like unto it. When the type system says that something is const, it means it. D's const is also transitive, meaning that if something is const, then everything it contains or refers to is also const. If D code has the equivalent of const vector<T*>, then not only is the container treated as const, but the elements are as well. Aside from whether these changes are an improvement over C++, the fact that D has immutable actually makes them necessary, because a const reference could refer to an object that's immutable underneath the hood, so mutating a const object via a backdoor not only risks violating the guarantees that immutable provides (namely that the object is not mutated via any reference rather than that it's not mutated via a particular reference, as is the case with const), but if an immutable object is in ROM, then mutating it could crash the program. So, the type system really can't afford to allow the program to mutate const under any circumstances.

And in D, it's undefined behavior to cast away const and mutate the object even if the programmer knows for a fact that it's mutable underneath the hood rather than immutable. An object can still be mutated via a mutable reference to the same data, but there's no way to get at a mutable reference to an object from a const reference without casting, at which point mutating the data is undefined behavior. So, only mutable references that were obtained separately from the const reference can be used to mutate the data. And since D's pure guarantees that a function does not access any global mutable state except through its arguments, it becomes very easy in D to write a function that guarantees that it won't mutate its arguments - something which is impossible in C++. This means that D's const is far more than just documentation. It provides real, compiler-backed guarantees that the data will not be mutated through a const reference to that data. Unfortunately, however, those guarantees come at a cost.

For instance, in C++, if a type needs to ensure that access to a variable is always protected with a mutex, even in a const function, it can just mark the mutex as mutable and treat it as not being part of the state of the object. In fact, the mutex can be added later without changing the class' public API at all. However, that's not possible in D. There is no way to make it so that the mutex is mutable while the rest of the object is const. Once the object is const, the whole thing is const. So, to have an object with a mutex inside it, const can't be used. And if a mutex has to be added later, then that's a huge change to the API, because then const has to be removed frome every member function marked as const that uses the mutex, and any code that uses const with that type will break. If the programmer is willing to give up on pure, that can be gotten around by putting the mutex outside of the object, but that's obviously not ideal. Now, in the case of classes, there's actually a backdoor of sorts in that classes have a built-in monitor object that acts as a mutex for synchronized functions that's not treated as part of the object itself with regards to const but which also isn't treated as global state, so it can be used with pure. So, it's possible to use a mutex with const synchronized functions, but there is no such solution for structs, and it only works with a single mutex. So, if more mutexes are needed, or if anything other than a mutex needs to be treated as being separate from the object's state in spite of being inside the obect, then that implicit monitor object doesn't help at all, and classes can't use const any more than structs can. So, in any other case that's similar but does not actually involve a mutex (or which requires multiple mutexes), the class is going to have to give up on const too.

Similarly, lazy initialization is impossible with const in D. In C++, there are cases where certain portions of an object are not initialized until they're needed (usually to improve performance), and if mutable or casting is used, those member variables can be initialized in a const member function if that's where they're needed first. But in D, because there is no backdoor for const, either all member variables must be initialized in the constructor, or a temporary value will have to be used in place of the member variable if a const member function is called before the member variable has been initialized. Either that or the state needs to be put outside of the object instead of in a member variable, in which case, pure can't be used. So, in practice, const and lazy initialization really don't work well together.

Another big problem is the inability to get a tail-const version of a const object with user-defined types. In the case of a pointer, it can be const (meaning that both it and what it points to are const because of transitivity), but the pointer itself can be copied to get a mutable pointer while leaving what it points to as const. e.g.

const(int*) p1 = getPtr();
const(int)* p2 = p1;

So, the data is protected, but the pointer to the data doesn't have to stay stuck pointing at the same thing. Similarly, dynamic arrays have that ability. e.g.

const(int[]) arr1 = getArray();
const(int)[] arr2 = arr1;

So, slicing the dynamic array results in a tail-const slice such that the dynamic array itself can be mutated to refer to fewer elements, or elements can be appended to it, but the elements themselves can't be mutated. And in the case of dynamic arrays, the compiler is even nice enough to instantiate templates with a tail-const slice of the dynamic array rather than its actual type. This means that it's possible to do something like

const int[] arr = [1, 2, 3, 4];
bool result = arr.startsWith(7);

If the compiler instantiated the template with the exact type, then startsWith wouldn't compile, because it needs to mutate the range that it's given in order to iterate through it. Originally, templates were always instantiated with the exact type, and that caused all kinds of compilation errors when using const or immutable dynamic arrays with templated functions. IIRC, it was my complaints about that which got that changed, but regardless, the current behavior is that templates are instantiated with a slice of the dynamic array, resulting in the template being instantiated with the tail-const or tail-immutable version of the dynamic array's type, and that makes dealing with const and immutable and dynamic arrays much more pleasant. However, it's just a workaround for the core problem, and that's that ranges are incompatible with const and immutable.

A range must be mutated in order for it to be iterated. Each call to popFront reduces the length of the range and makes it so that front then points to the next element (or the range is empty if the previous front was the last element). Since that requires mutation, it is fundamentally incompatible with const and immutable. If we'd gone with more of a head/tail / car/cdr model and made it so that a range was iterated by creating a new range which was one element shorter, then it would have worked with const and immutable, but it might also have had performance problems in some cases, since it's frequently faster to mutate an object than to copy it. It also wouldn't work well with a range that couldn't be a forward range (i.e. one where it's not possible to have two ranges iterating over the data independently), because nothing would prevent code from calling tail (or whatever it would be called) multiple times on the same range. A similar problem still exists when a range which is not a full-on reference type is copied, and both the original and copy are used (since that results in range-specific behavior that is going to be buggy), but with a head/tail model, we'd end up with both of those problems, whereas now, we just have the copying problem.

If we had a way to get a tail-const or tail-immutable copy / slice of a range, then functions could just slice const and immutable ranges and then iterate over the second range, but not only is that not part of the range API, but there isn't currently a good way to do that with user-defined types. Dynamic arrays can do it, because the compiler understands how they work. It knows that getting a tail-const slice of a const dynamic array is perfectly safe. However, with a user-defined type, the compiler doesn't know that. And in fact, in the case of templated types (which ranges almost always are), the template instantiations for a mutable and const type could have nothing to do with each other. Range!T and Range!(const T) could be completely different thanks to stuff like static if, and while const(const(T)[]) is equivalent to const(T[]), const Range!T may not be at all equivalent to const Range!(const T). So, the compiler has no clue how to get a tail-const version of a user-defined range like it does with dynamic arrays. And in practice, that means that const and immutable cannot be used with ranges, and any type which contains a range or returns a range from a const or immutable member function can't function properly.

Occasionally, there has been talk of adding something like opTailConst so that user-defined types can define a way to get a tail-const version of themselves that the compiler then knows how to use, but nothing along those lines has yet been implemented, and adding it would make dealing with const that much more complicated. So, it would very much be a double-edged sword. In fact, it's problems like these which lead to many people giving up on const entirely in their D code.

A related problem is that const and generic code don't tend to go well together. For const to work in generic code, the API that's being duck-typed needs to include const as part of what it expects; otherwise, there's a good chance that it won't work with some template arguments. The prime example of this is - again - ranges. No function in the range API is defined by the API as being const, and there are ranges for which functions like front and empty can't be const (e.g. because of transitivity, having front be const frequently does not play well with an element type that is a reference type, since that forces the elements and everything they refer to be const with no way to get a mutable copy (unlike value types), and that's frequently so restrictive that it's useless). So, even ignoring the issue with popFront, member functions for ranges generally can't be const. While some ranges can mark empty or front as const, no generic algorithms can do so, because they could be used with ranges which don't - and sometimes can't - have those properties be const. And since idiomatic D code frequently involves a lot of generic code, const becomes that much more limited in its usefulness.

Another problem with const is that when using classes, once a base class function is const, every derived class must work with const. It's possible for a derived class to declare an overload which isn't const, but it can't ignore the base class function. Either it has to alias it so that it's in the overload set of the derived class, or it has to override it in addition to adding an overload that isn't const. Either way, it has to work with const, severely limiting its ability to do stuff like lazy initialization. The same is true to an extent in C++, but in C++, there are backdoors to get around const, whereas in D, there aren't. And of course, if the base class does not mark the function as const, it's then not possible to use const with a base class reference, even if the derived class marks its override with const - though it is possible to then use const so long as the references used are always for the derived class or one of its descendants and not for the base class.

The most prevalent place that the problem with a base class function not being marked as const pops up is with the four member functions that are built into Object - opEquals, opCmp, toHash, and toString. None of those functions are marked with const, which means that it's not legal to call them on const Object references - though if only derived references are used, const can be added and used just fine. What makes this even worse though is that == and != don't call opEquals on the class reference directly. They call the free function, opEquals in object.d, which does stuff like avoid calling the member functions if one of the references is null and ensure that both lhs.opEquals(rhs) and rhs.opEquals(lhs) are true (which avoids problems when comparing base classes against derived classes). Its current implementation is:

auto opEquals(Object lhs, Object rhs)
{
    // If aliased to the same object or both null => equal
    if (lhs is rhs) return true;

    // If either is null => non-equal
    if (lhs is null || rhs is null) return false;

    // If same exact type => one call to method opEquals
    if (typeid(lhs) is typeid(rhs) ||
        !__ctfe && typeid(lhs).opEquals(typeid(rhs)))
            /* CTFE doesn't like typeid much. 'is' works, but opEquals doesn't
            (issue 7147). But CTFE also guarantees that equal TypeInfos are
            always identical. So, no opEquals needed during CTFE. */
    {
        return lhs.opEquals(rhs);
    }

    // General case => symmetric calls to method opEquals
    return lhs.opEquals(rhs) && rhs.opEquals(lhs);
}

auto opEquals(const Object lhs, const Object rhs)
{
    // A hack for the moment.
    return opEquals(cast()lhs, cast()rhs);
}

Note the second overload. It casts away const. Without that, comparing any class references where at least one was const would be illegal, because Object.opEquals is not const. As the comment attests to, it's a hack, and it makes it quite easy to get undefined behavior. As long as the derived class implementations for opEquals are const or at least could be marked as const, because they don't mutate the object at all, then everything is fine. But if a class' opEquals isn't marked with const, then there's no error if it does anything that mutates the object (e.g. lazy initialization) - even when == or != is used with a const object. And if a const object is used with == or != when its opEquals mutates the object, that's undefined behavior and could cause subtle bugs. D's runtime has a hack to work around the fact that Object.opEquals wasn't marked as const, because marking it as const now would break all code that declares a class' opEquals to be mutable, and making it const would also make it impossible to do lazy initialization in opEquals, whereas right now, that works just fine so long as const isn't used. But it's still a hack, and it makes it quite easy to introduce undefined behavior without realizing it.

The solution to this problem which was agreed to a few years ago was to remove opEquals, opCmp, toHash, and toString from Object so that derived classes can define them with whatever attributes are desired (not just for const, but for attributes such as pure and nothrow as well). D's templates generally negate the need to use Object as a base type for things like comparison, so unlike languages like Java, in D, these functions don't need to be on Object. The fact that they are is result of D1 not having templates early on in its development. With those functions being declared on derived classes and not on Object, class hierarchies would then have to live with whatever set of attributes were put on the base class that added those functions to the class hierarchy, but each code base could use whichever set of attributes made the most sense for it, reducing the problem, whereas now, everyone is stuck with the choices that were made for Object.

Unfortunately, removing those functions from Object is a bit difficult to do without breaking tons of code, and there are some implementation issues that need to be fixed first for it to be possible (most notably, the built-in AA implementation needs to be templatized, which has been a work in progress for years but has yet to reach the point where it's been merged into druntime). So, it's not at all clear that any of these functions will ever be removed from Object, and given the growing user base of D and thus the increase in breakage that would be caused by such a change, it seems increasingly likely that they will never be removed from Object. There has been some recent discussion of possibly writing a DIP to add a new root object below Object which would then not have those functions and allow classes to derive from it, bypassing Object, which may provide an alternative to work around the problem, but whether anything will come of that remains to be seen. Of course, all of this would be much easier to fix if we didn't care about breaking code, but since we do, it's a much harder problem to solve, and we continue to have to deal with it.

Now, this problem is a rather extreme case given that these functions are on the root class that all of D's classes are derived from, but the basic problems that stem from whether a base class function is marked with const or not affect the classes that are declared in any D program, whereas languages like Java or C# simply don't have these sorts of issues, because they don't have const. And while C++ does have some of these same issues, the fact that it has backdoors to get around const reduces the problem, whereas because D insists on fully transitive const with no backdoors, it's stuck in a bit of a quagmire.

Another fundamental problem with const in D is how structs are copied. Instead of C++'s copy constructor, which explicitly sets all of the member variables of an object, D has postiblit constructors, and the way they work is to copy the entire object and then give the programmer the chance to make modifications after the copy has been made. So, where C++ might have something like

class C
{
public:

    const C& C(const C& rhs)
        : _member1(rhs._member1),
          _member2(rhs._member2),
          _member3(rhs._member3)
          _member4(rhs._member4)
          _member5()
    {
        // do something fancier to copy _member5...
    }

// ...

};

D would have

struct S
{
public:

    this(this)
    {
        // do something fancier to copy _member5...
    }

// ...

};

This generally makes copying structs in D much easier and less error-prone. However, it comes with a pretty serious flaw: it only works with mutable objects.

The problem is that the entire object must be fully initialized before the body of the postblit constructor is run. That means that any member variables which are const or immutable are stuck at whatever they were in the original object, because it would violate the type system to mutate them. And if an object is const or immutable, then that's all of the members. This issue is one reason why it's often considered good pratice to avoid making the member variables of a struct const or immutable, whereas it's not a problem for classes, since they're reference types and aren't copied.

Now, for immutable, this really isn't a problem, because there isn't normally a reason to make a deep copy of an immutable object. It's already guaranteed that the object won't ever be mutated by anything in the program, so all of the immutable objects with the same value can share references to data without causing problems, whereas for const, it's frequently possible to have a mutable reference to the same data. So, just like with mutable objects, a deep copy needs to be made in order to for the copy to not be affected by another, mutable reference to the same data. That means that const objects need a way to define a constructor that defines how they're copied, but postblit constructors fundamentally don't work with const, and D doesn't have copy constructors. So, at the moment, any structs that need a postblit constructor don't work if they're marked with const.

Some folks who don't understand postblit constructors well enough try to mark the postblit constructor with const to make it work, but that currently results is some obtuse compilation errors related to the runtime trying to call the postblit constructor and being unable to, and it arguably shouldn't be legal (though some have argued that it would be useful for printing out when an object is copied). Regardless, actually copying a const object with a postblit constructor can't work, because the postblit constructor would have to mutate the object, which it can't do, because the object is const.

Several years ago, one of the compiler developers, Kenji Hara, tried to define a way to make it work to have a postblit constructor copy a const object, but it turned out to be far too complicated, and it got rejected on those grounds. To do it would effectively require putting off the initialization of members that were initialized in the postblit constructor, and that gets nasty fast. Probably the only sane way to fix the problem is to introduce copy constructors, but that then raises the question of whether they should replace postblit constructors (and if const isn't involved, postblit constructors are definitely superior, since they involve less code and are less error-prone). If we replaced postblit constructors, that would break a lot of code, and if we added copy constructors in addition to postblit constructors, that complicates the language considerably with regards to copying. No one has yet to propose a solution that's considered acceptable, so the status quo is that const simply isn't used with objects that have postblit constructors.

And really, that tends to be the approach to const in D in general: don't use it. Many of us have decided that it simply isn't worth it. Some folks do try to use it as much as possible, and if code is mostly dealing with the built-in scalar types and dynamic arrays, it can actually do quite a lot with const, because the restrictions aren't as prevalent there as they are with user-defined types (in part because of workarounds in the language for built-in types like using the tail-const version of a dynamic array's type when trying to instantiate a template with a const dynamic array). But once user-defined types are used heavily - especially with ranges, it starts getting a lot harder to use const, and it quickly reaches the point for many folks where it simply isn't worth it.

So, whereas in C++, const doesn't guarantee much, but it can be used all over the place, in D, const guarantees quite a lot, but the cost is that it's very hard to use much of anywhere.

Conclusion

Ultimately, I really don't see a good solution with regards to const. I use C++'s const everywhere and try to make my C++ code "const-correct," because I like the fact that it documents which functions are supposed to be mutating the logical state, and at least in principle, the fact that once in a blue moon it might prevent a bug certainly doesn't hurt, but the lack of transitivity and the complete lack of real guarantees makes it seem rather flimsy. I use it, but I'm not at all happy with it.

On the flip side, the fact that D's const is transitive and provides real compiler guarantees is great, but it also sucks, because those guarantees tend to actually be way too restritive to be useful much of the time - especially in idiomatic D code. So, I'm not particularly happy with D's const either, but in its case, rather than using it in spite of not being happy with it, I mostly don't use it.

If I'm dealing with the built-in scalar types, I almost always use immutable, not const, since in that case, they're equivalent, but immutable better captures the intent (i.e. that it never mutates the data as opposed to it never mutates via that reference to the data). And whether I use const or immutable with arrays tends to depend on whether the elements were originally mutable or immutable. But once user-defined types get involved, I do almost nothing with const, and I pretty much never use const with templated code. At most, I use const with member functions for non-templated types where const clearly isn't going to cause problems.

Essentially, C++'s const can be used heavily and does provide some protection when used properly, but its protection is ultimately limited and easily bypassed, whereas D's const provides full protection whenever it's used, and that protection cannot be bypassed, but it can only rarely be used because of how restrictive those protections make it. Neither is ideal.

So, I'm not happy with const in either C++ or D, but I also have no clue how the problem could be solved in a reasonable way. Without backdoors, const frequently cannot be used, but if it has backdoors, then it doesn't provide real guarantees. I think that for it to truly be useful, the compiler would have to somehow be able to define and understand logical const in a sane way, but I don't think that that can actually be implemented - and certainly, it can't be implemented if immutable is involved, since as long as a const reference could be referring to an immutable object underneath the hood, no portion of it could be mutated under any circumstances. So, even if we could do it, it would mean either introducing a new kind of const that couldn't refer to immutable objects or just making const and immutable incompatible, which would destroy one of the few places where const is actually useful in D. Maybe someone will come along at some point who will be able to figure out how to design const in a way that's truly useful, but it's a very hard problem.

And thus my conclusion is that const sucks - both in C++ and D - and while some aspects can certainly be improved, I don't foresee a point where const in C++ or D isn't terrible. And this is coming from someone who would really like const to be truly sane and useful and who might not have picked D if it didn't have const.

- Jonathan M Davis(2018-03-05)




Copyright © 2018 - 2019 by Jonathan M Davis | Page generated by Ddoc at 2019-09-01T07:32:18Z