Twenty plus years ago, I would have (and didn't) think anything of doing binary I/O with POD structs:
struct S { std::uint32_t x; std::uint16_t y; };
S s;
read(fd, &s, sizeof(s)); // assume this succeeds and reads sizeof(s) bytes
std::cout << s.x + s.y;
(I'm ignoring padding and byte order issues, because they're not part of what I am asking about.)
"Obviously", we can read into s
and the compiler is required to assume that the contents of s.x
and s.y
are aliases by read()
. So, s.x
after the read()
isn't undefined behaviour (because s
was uninitialized).
Likewise in the case of
S s = { 1, 2 };
read(fd, &s, sizeof(s)); // assume this succeeds and reads sizeof(s) bytes
std::cout << s.x + s.y;
the compiler can't presume that s.x
is still 1
after the read()
.
Fast forward to the modern world, where we actually have to follow the aliasing rules and avoid undefined behaviour, and so on, and I have been unable to prove to myself that this is allowed.
In C++14, for example, [basic.types] ¶2 says:
For any object (other than a base-class subobject) of trivially copyable type T, whether or not the object holds a valid value of type T, the underlying bytes (1.7) making up the object can be copied into an array of char or unsigned char.
42 If the content of the array of char or unsigned char is copied back into the object, the object shall subsequently hold its original value.
¶4 says:
The object representation of an object of type T is the sequence of N unsigned char objects taken up by the object of type T, where N equals sizeof(T).
[basic.lval] ¶10 says:
If a program attempts to access the stored value of an object through a glvalue of other than one of the following types the behavior is undefined:54
...
— a char or unsigned char type.
54 The intent of this list is to specify those circumstances in which an object may or may not be aliased.
Taken together, I think that this is the standard saying that "you can form an unsigned char
or char
pointer to any trivially copyable (and thus POD) type and read or write its bytes". In fact, in N2342, which gave us the modern wording, the introductory table says:
Programs can safely apply coding optimizations, particularly std::memcpy.
and later:
Yet the only data member in the class is an array of char, so programmers intuitively expect the class to be memcpyable and binary I/O-able.
With the proposed resolution, the class can be made into a POD by making the default constructor trivial (with N2210 the syntax would be endian()=default), resolving all the issues.
It really sounds like N2342 is trying to say "we need to update the wording to make it so you can do I/O like read()
and write()
for these types", and it really seems like the updated wording was made standard.
Also, I often hear reference to "the std::memcpy()
hole" or similar where you can use std::memcpy()
to basically "allow aliasing". But the standard doesn't seem to call out std::memcpy()
specifically (and in fact in one footnote mentions it along with std::memmove()
and calls it an "example" of a way to do this).
Plus, there's the fact that I/O functions like read()
tend to be OS-specific from POSIX and thus aren't discussed in the standard.
So, with all this in mind, my questions are:
What actually guarantees that we can do real-world I/O of POD structs (as shown above)?
Do we actually need to need to std::memcpy()
the content into and out of unsigned char
buffers (surely not) or can we directly read into the POD types?
Do the OS I/O functions "promise" that they manipulate the underlying memory "as if by reading or writing unsigned char
values" or "as if by std::memcpy()
"?
What concerns should I have when there are layers (such as Asio) between me and the raw I/O functions?
Strict aliasing is about accessing an object through a pointer/reference to a type other than that object's actual type. However, the rules of strict aliasing permit accessing any object of any type through a pointer to an array of bytes. And this rule has been around for at least since C++14.
Now, that doesn't mean much, since something has to define what such an access means. For that (in terms of writing), we only really have two rules: [basic.types]/2 and /3, which cover copying the bytes of Trivially Copyable types. The question ultimately boils down to this:
Are you reading the "the underlying bytes making up [an] object" from the file?
If the data you're reading into your s
was in fact copied from the bytes of a live instance of S
, then you're 100% fine. It's clear from the standard that performing fwrite
writes the given bytes to a file, and performing fread
reads those bytes from the file. Therefore, if you write the bytes of an existing S
instance to a file, and read those written bytes to an existing S
, you have perform the equivalent of copying those bytes.
Where you run into technical issues is when you start getting into the weeds of interpretation. It is reasonable to interpret the standard as defining the behavior of such a program even when the writing and the reading happen in different invocations of the same program.
Concerns arise in one of two cases:
1: When the program which wrote the data is actually a different program than the one who read it.
2: When the program which wrote the data did not actually write an object of type S
, but instead wrote bytes that just so happen to be legitimately interpret-able as an S
.
The standard doesn't govern interoperability between two programs. However, C++20 does provide a tool that effectively says "if the bytes in this memory contain a legitimate object representation of a T
, then I'll return a copy of what that object would look like." It's called std::bit_cast
; you can pass it an array of bytes of sizeof(T)
, and it'll return a copy of that T
.
And you get undefined behavior if you're a liar. And bit_cast
doesn't even compile if T
is not trivially copyable.
However, to do a byte copy directly into a live S
from a source that wasn't technically an S
but totally could be an S
, is a different matter. There isn't wording in the standard to make that work.
Our friend P0593 proposes a mechanism for explicitly declaring such an assumption, but it didn't quite make it into C++20.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With