Short version: sizeof in C++ can be misleading and cause problems, particularly inside assignment overloads, and public/private has surprising effects on alignment.
Object alignment requirements can cause some subtle and hard-to-diagnose bugs in C++. The demonstrations here show two issues that have caused problems that I ended up debugging, and I hope they help others find similar issues buried in their code. (Issues seen with gcc-10, gcc-14, clang-14, clang-17, and others. Note that all examples here are distilled down from much more complex code, so many questions about alternative implementations are out of scope.)
First, some background.
Most C and C++ programmers are aware that struct and class members are placed on "natural" alignment boundaries. This placement is dependent on CPU architecture and a number of other factors (including "packed" options supported by some, but not all, compilers and architectures), but the most common rule is that by default each member is placed at an offset is that is a multiple of the size of the object. That is, a 'char' is on any byte boundary, a two-byte 'short' is on an even byte boundary, a four-byte 'int' is on a multiple of 4 bytes, and so on. If the offset after the preceding element doesn't provide the right alignment, then the compiler automatically inserts hidden padding to make it right. For example:
struct {The overall size of that struct above may be either 3 or 4 (or perhaps even more), depending on the requirements of the platform. This is why it's somewhat common practice to put the large objects first in a structure, followed by smaller ones, or to group small objects together in clusters. Both strategies minimize this wasteful padding. Note that this padding is between successive members in a structure.
/* must be at offset 0
in the struct */
char first;
/* offset 2; 1 one byte
pad inserted before */
short second;
};
In general, these size-based rules apply to fundamental types, and do not directly apply to compounds (nested structures). Instead, a compound has composite size and alignment based on its contents. That is, a struct has a required alignment that is equal to the strictest (largest) requirement of any member inside, and not its overall size.
A subtle corollary of the above rules is that sizeof() on a struct (or class) must return a value that is rounded up based on the alignment of the strictest member inside, effectively producing trailing hidden padding. That's because sizeof() must always give the proper stride of objects within an array. For example:
struct {This struct should have size 6 (assuming a 4-byte int and 2-byte short), with no padding. But the natural alignment of that 'int' is on a 4-byte boundary, so the sizeof() will be 8, making sure that adjacent array entries are all on natural alignment boundaries when multiple objects are allocated. Note that this shows trailing padding, after the last member.
int val1;
short val2;
};
The first problem, shown in align-surprise1.cpp (see attachments at the end of this post), is that the C++ compiler will naturally pack together member variables using an alignment that is less strict than the alignment required for the base class. This requires some explanation.
The definition of SimplePOD includes a naive optimization: we know that all of the members are just plain old data, and the class itself is not virtual (thus has no vft pointer to worry about), so why not take advantage of that, and use memset/memcpy rather than individually assigning each object?
The subtle error here is that the size being copied includes that alignment padding at the end, and the compiler is free to insert other, unrelated members inside that padding. It can't place them between members (as best I can tell), but the trailing padding is fair game.
In this case, this means that the data copied by the SimplePOD assignment operator includes an extra 4 bytes (actual length is 20 but sizeof is 24). This means that the private members in both TestClass and TestClass2 are overwritten by the assignment of the base class. This test just shows the effect of that issue, which is effectively memory corruption. In the case where I originally encountered this problem, the contents of a smart pointer was copied, resulting in a reference count mismatch and a crash.
You can see the problem demonstrated by running "make" and then executing "./surprise1-fail". The fixed version is built as "./surprise1-pass".
The fix (enabled by FIX_BUG) computes the actual size of SimplePOD and uses that for the copy. Note that commenting out the assignment operator overload in SimplePOD also fixes the problem, as the compiler will internally compute the correct amount to copy. C++ itself just provides no convenient means for the user to compute this value, which is important if an overload is needed by the overall class design.
The second problem is shown by align-surprise2.cpp and is even more shocking. In some cases, changing from "public" to private or protected will cause the member alignment and the overall object size both to change. This is demonstrated by the output of "./surprise2-public" and "./surprise2-private". The only difference is whether the members are public or private.
The surprise2-public output looks like this:
baz 12 foo 8This shows that the size of baz is 12, and that the alignment of 'c' starts on the next int boundary. But surprise2-private (and surprise2-protected) show this:
a 0
b 4
c 8
d 9
baz 8 foo 8The size of baz is down to 8 and the alignment of 'c' has changed to start on a char boundary. Again, the only change is the visibility of the members.
a 0
b 4
c 5
d 6
This has several implications. One is that if (say) you are debugging a problem and change some members from private to public just to simplify some temporary debug code, you might also be inadvertently changing the actual offset of those members within the object. If the entire project isn't recompiled, you could have mysterious behavior or even crashes as a result. Another is that seemingly innocuous improvements in C++ code (for example, making public members private and providing accessors instead) could easily change the size of the object and affect cache alignment, drastically altering performance by creating new opportunities for false sharing. Still another is that if you cast pointers back and forth between different classes, you may find that the actual offsets of members in those classes depend on the visibility specified, and thus memory corruption may occur.
It's a jungle out there!
https://www.workingcode.com/align-surprise.tar.gz
https://www.workingcode.com/align-surprise.zip
No comments:
Post a Comment