How does binary I/O of POD types not break the aliasing rules?
Asked Answered
N

2

7

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?

Nemhauser answered 27/7, 2019 at 17:3 Comment(13)
Not sure why there would be any aliasing issues here. Going through void* doesn't break any rules since it's impossible to access memory through a void*. All the compiler knows is that "someone" got the address of s and thus the memory s occupies might get modified. That's all it can know and it doesn't seem like it would break any rules.Methylnaphthalene
"What actually guarantees that we can do real-world I/O of POD structs (as shown above)?" Probably the fact that oodles of existing code would not work.Variation
@Doug: I know (or at least always thought I knew) that, "yes, it does work" , but I'm trying to get a complete understanding of what combination of rules allow it. I think @Nikos C. Is pointing out what I've missed: pointing to a type as void* (or char*) is a legal alias, and then as long as you use the memory via the original S* or char*, you're good Maybe it's that simple.Nemhauser
It's an excellent question. One of the dark areas of the Standard v actual practice. My concern is reading into vectors<POD>data() where the object to object packing has been verified. The problem there is that all data has been initialized. If a compiler decided an object in the vector hadn't been changed by the read there be dragons.Variation
unclear what problem you view here at all. you pass address of object to external and unknown for compiler funtion. this function can modify object memory (because it take it address), after this compiler can't presume what content will be in struct. What actually guarantees that we can do real-world I/O of POD structs - known memory layout of S - if c++ garantee that x have 0 offset and 4 byte size, and y - offset 4 and 2 byte size, and sizeof(S) == 8 - you can pass &s to some external for language api (which can be implemented on another language).Euchre
visa versa - if layout of some struct don't know/guarantee by language layer - you can not pass it to external code. so here question - are memory layout of your POD is well -known and garantee by c++ -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()"? - this is senseless question. OS code can at all dont understand your language. it operate on another layer. OS code ghave pointer to memory address and size. and can do any.Euchre
are result will be correct - when and only when you and os have equal view on POD memory layoutEuchre
@RbMm: I don't view any "problem" here other than trying to understand how the standard plus any other rules work together to make it "legal" to: 1) change the value of s and its members via I/O without breaking aliasing rules (and thus without having the compiler ignore the result of the I/O ), and 2) read in some bytes and create a valid S. I think the answers are: 1) as per @Nikos C. it simply doesn't break the aliasing rules, and 2) as per the full answers below, such a type has (at least) standard layout and sane compilers use sane standard layouts.Nemhauser
@BradSpencer- why you say about I/O only ? question must be more generic - are we can pass address of c++ object to some external function. this it absolutely does not have to be I/O. so question - can we even interact with external components. so question here i be say 2: 1 - are memory layout of POD well known (how i know yes of course) 2 are can compiler ignore that we pass address of object to external function and assume that object not changed after this. i dont know what formal standard say about this, but understand that, after we pass address of object -Euchre
compiler will drop all assumption about object memory - so if you read some field of object after - compiler will direct read it from object memory, but not use any cached value before external callEuchre
and aliasing here unrelated at all. how i know strict aliasing - that when compiler assume that 2 pointers to different types cannot overlap in memory. as result when we modify memory via one pointer, compiler assume that memory to which point second pointer - still not changed. but here all this unrelatedEuchre
I asked specifically about I/O because it has some special properties that mean it reaches outside the scope of the standard or a single program instance.Nemhauser
@BradSpencer - i not think that concrete I/O have some special properties. this is question about any external call. any call to os api for example. to another binary. i/o or not i/o absolute unrelated to questionEuchre
P
6

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.

Portland answered 27/7, 2019 at 20:0 Comment(9)
Hmm... assuming an object that "totally could be an S" is layout-compatible with S, then it might be fine to use std::bit_cast, albeit probably with a std::memcpy() first. I'm not 100% certain about this, though, since to my knowledge there aren't really any rules for what layout compatibility actually does for you.Palenque
@JustinTime: "there aren't really any rules for what layout compatibility actually does for you" Sure there are. Layout compatibility is part of the common initial sequence rule. If two types are layout compatible, then their common initial sequence is the totality of the object. So in a union, you can read from all the members of one even though the other is active.Portland
Good point, I forgot about the union thing. I was mainly thinking about the case of reading an A's bit representation as layout-compatible object B, as the question's underlying issue. And in that situation, I couldn't recall any guarantees provided by or benefits of layout compatibility that would prove useful.Palenque
It seems that perhaps concerns 1 and 2 are essentially the same. After all, what does it even mean for a different program to also have a type S? Consider if the token sequence of the definitions of two classes were the same in two programs: are they really both the same type? Conversely, something like the names of the class and members, for example, couldn't (shouldn't?) possibly matter, because if two same-tokens S types are the same in two programs, then surely naming one T wouldn't change that. It's unclear what sameness could mean here beyond "same bit representation".Nemhauser
And if sameness boils down to "same bit representation" , I think that puts us in Quality of Implementation territory, which means the answer is basically: because your compiler isn't useless, which is similar to what @supercat said.Nemhauser
P0593 is very interesting, and its I/O example was especially pertinent, but also "the opposite" in that it wants to take a char[] and use the same storage as a type Foo, whereas I want to take Foo and initialize its storage by way of char[]. If I understood correctly, my way's only "problem" is relying on what the standard layout is vs. what bytes I read, whereas the paper has that "problem" and an object lifetime problem. Thanks!Nemhauser
"I want to take Foo and initialize its storage by way of char[]." But that's not really what you're doing. There is no char[]; you're just reading bytes into Foo's value representation. "standard layout" Standard layout has nothing to do with this. Remember: it's trivial copyability that makes this valid or not valid. Standard layout may inform you about ways to build data out of scalars, but it isn't necessary for dealing with the problem of reading structs from files.Portland
Oh, I thought that, in the model of the abstract machine, the only way bytes can be placed into Foo is by char[], so even though the external function can be anything, I presumed the standard would model it "as if" it were in its own terms. But if "put bytes here, abstractly" is a thing, then sure. I guess I need to go re-read what standard layout is because if I think that if a type doesn't have standard layout, I can't know what order its fields are in. And if I want to load S from bytes that didn't come from the same program (like in the real world) I think I need to know that orderNemhauser
C++03 does have all the same pieces in [basic.types] and [basic.lval], except there is no std::byte, it uses "POD type" instead of "trivially copyable type", and it uses "lvalue" instead of "glvalue" (since there were no xvalues).Imre
S
-1

The type-access rules in every version of the C and C++ Standard to date are based upon the C89 rules, which were written with the presumption that implementations intended for various tasks would uphold the Spirit of C principle described in the published Rationale as "Don't prevent [or otherwise interfere with] the programmer from doing what needs to be done [to accomplish those tasks]." The authors of C89 would have seen no reason to worry about whether or not the rules as written actually required that compilers support constructs that everyone would agree that they should (e.g. allocating storage via malloc, passing it to fread, and then using it as a standard layout structure type) since they would expect such constructs to be supported on any compiler whose customers would need them, without regard for whether or not the rules as written actually required such support.

There are many situations where constructs which should "obviously" work, actually invoke UB, because e.g. the authors of the Standard saw no need to worry about whether the rules would e.g. forbid a compiler given the code:

struct S {int dat[10]; } x,y;
void test(int i)
{
  y = x;
  y.dat[i] = 1; /// Equivalent to *(y.dat+i) = 1;
  x = y;
}

from assuming that object y of type struct S could not possibly be accessed by the dereferenced int* on the marked line(*), and thus need not be copied back to object x. For a compiler to make such an assumption when it can see that the pointer is derived from a struct S would have been universally recognized as obtuse regardless of whether or not the Standard would forbid it, but the question of exactly when a compiler should be expected "see" how a pointer was produced was a Quality of Implementation issue outside the Standard's jurisdiction.

(*) In fact, the rules as written would allow a compiler to make such an assumption, since the only types of lvalue that may be used to access a struct S would be that structure type, qualified versions of it, types derived from it, or character types.

It's sufficiently obvious that functions like fread() should be usable on standard-layout structures that quality compilers will generally support such usage without regard for whether the Standard would actually require them to do so. Moving such questions from Quality of Implementation issues to actual conformance issues would require adopting new terminology to describe what a statement like int *p = x.dat+3; does with the stored value of x [it should cause it to be accessible via p under at least some circumstances], and more importantly would require that the Standard itself affirm a point which is currently relegated to the published Rationale--that it is not intended to say anything bad about code which will only run on implementations that are suitable for its purpose, nor to say anything good about implementations which, although conforming, aren't suitable for their claimed purposes.

Sanmicheli answered 27/7, 2019 at 20:54 Comment(14)
The example confuses me. I'm not sure what temp refers to. More significantly, I don't understand how y.dat[i] = 1 would in any way be an aliased access of the content of y. Surely the rules for accessing classes must be written such that accessing members through uncasted references (with . and ->) doesn't produce aliasing or the opportunity to ignore such an access. I admit I don't know the rules, but I'm not sure I'm ready to follow on the leap that "the rules as written ... allow a compiler to make such an assumption".Nemhauser
@BradSpencer: Sorry--I reworked my example and failed to rename the object in the commennt. While y.dat[i] isn't an aliased access to the content of y, the normative part of the Standard doesn't limit its application to situations which involve aliasing in the code as written. If an object is accessed by two references, but accesses are separated by an action that derives the second from the first, they don't alias, but gcc and clang will sometimes replace a reference that was derived from another with a reference that identifies the same object but wasn't so derived, and ignore...Sanmicheli
...the derivation. For example, given T1 *p1 = &unionArray[i].member1; useT1(p1); T2 *p2 = &unionArray[j].member2; useT2(p2); T1 *p3 = &unionArray[i].member1; useT1(p3);, clang and gcc will substitute p1 for p3, and ignore the fact that the derivation of p3 occurs between the last use of *p2 and the next use of *p3.Sanmicheli
Types of subobjects are valid aliases for the complete object. Given your struct S, if we had void f(int* p) { y = x; *p = 0; x = y; } and void g(long* p) { y = x; *p = 0; x = y; } then g may skip the final x = y, but f may not, precisely because *p might be a subobject of y.Imre
@aschepler: At least in the C Standard, they're not listed as such, and for optimization purposes they shouldn't be. Given e.g. struct thing {int count; int *dat; } it; ... for (int i=0; i < it->count; it->dat[i]--;} the lack of any reference to it->count within the loop other than the comparison should entitle a compiler to treat its value as a loop invariant, despite the fact that the loop uses an int* of unknown provenance within the loop.Sanmicheli
@aschepler: That section allows a struct member lvalue to be accessed using an lvalue of an enclosing struct or union type. It includes no provision that allows for a struct or union object to be accessed via lvalue of member type.Sanmicheli
So struct thing x; int* p = &x.count; int n = *p; is by a strict reading incorrect?Imre
@aschepler: The range of situations where a compiler can recognize that a pointer is derived from an lvalue of another type is a Quality of Implementation issue, outside the jurisdiction of the Standard. The authors of the Standard have said they wanted to give programmers a "fighting chance" [their words] to write portable code, and did not wish to demean code that wasn't guaranteed to work on all implementations, and probably didn't think anyone would care about whether the Standard mandated support for constructs that all compilers should obviously support anyway.Sanmicheli
@aschepler: I think the authors of the Standard would have thought it obvious that (assuming struct thing th,*tp), if a compiler can see code convert &thor tp` to some other type, or take the address of member th.count or tp->count, then unless it can track everything done with the resulting pointer, it should treat the act of taking/converting the address as though it were a potential operation on th.count and tp->count, and also refrain from hoisting operations involving such lvalues ahead of operations on the resulting pointer type within the context where...Sanmicheli
...the derivation took place, except that it would probably be reasonable to hoist such operations to the top of a loop if no such derivations take place within it. Different compilers processed things differently, and the authors of the Standard wanted to avoid writing rules that would brand a useful compiler as "non-conforming" merely because of how it processed some obscure test case. As it is, the notion of "Effective Type" in C, or "dynamic type" for Standard Layout structures, is a fundamentally poor proxy for what rules should be concerned about, which is static program structure.Sanmicheli
So "yes it's technically undefined behavior, but everyone knows it obviously needs to work anyway"? I would just wonder exactly what "it" is...Imre
@aschepler: According to the authors of the Standard, the first two principles of the Spirit of C are "trust the programmer; don't prevent the programmer from doing what needs to be done". Further, they wanted "the marketplace" to make judgments about what "popular extensions" implementations should support, presumably based upon the what would be useful to their customers. Doesn't seem that hard.Sanmicheli
@aschepler: The Rationale gives an example of a situation where the rules were intended to facilitate optimization. In that example, a function received a double* and there was nothing within the function to indicate any relation between that pointer and any object of type int. Nothing in the Rationale suggests that the rules should be used as a basis for compilers to ignore apparent relationships when they exist. There are some corner cases where some compilers should probably optimize and some should hold off, based upon the kinds of tasks for which they are intended, but...Sanmicheli
...that's why the matter should be treated as a Quality-of-Implementation issue. If the only cases where it isn't obvious whether a construct should be supported are those where supporting the construct would make a compiler more suitable for some purposes and less suitable for others, then compiler writers and their customers should be much better positioned than the authors of the Standard to judge the costs and benefits of support in the context of he compiler's actual use cases.Sanmicheli

© 2022 - 2024 — McMap. All rights reserved.