8

As a follow-on to this question, I'm imagining a class which stores sensitive data, like cryptographic keys. To simplify things, assume there's no inheritance involved.

struct Credential {
  std::array<uint8_t, 32> secretStuff;
  ~Credential() { memset_s(secretStuff.data(), 32, 0, 32); }
}

I'm trying to determine if objects of this type are guaranteed to have their destructor run, or if I need to do something fancy like use an Allocator to ensure the memory is wiped. I'm interested in resiliency against compiler optimizations, so I'm looking for chapter-and-verse from the standards to assure me that I'm going to get the right behavior no matter what.

In previous questions, it's been established that objects in automatically-allocated and static storage are guaranteed to have their destructors run. I'm not interested in the static case; as far as I'm concerned it's the OS's job to make sure that the contents of previously-used memory don't leak once the program is terminated. I'm also not interested in cases where the programmer is deliberately breaking things... after all, there's nothing to say they can't just copy the data out in the first place.

Imagine you were a compiler author and wanted to break this while complying with the standard. Is there anything you could do to avoid calling the destructor (excepting program termination)? Maybe some strange exception handling behavior? And if you wouldn't be allowed to, why not, specifically?

Reid Rankin
  • 1,078
  • 8
  • 26
  • See [destructor](https://en.cppreference.com/w/cpp/language/destructor). The standard specifies that the destructor *should* run. Which means a compliant compiler is not allowed to have a side-effect such that `memset` isn't called. – Hatted Rooster Aug 15 '19 at 21:27
  • Oh, and `memset_s()` is just an example I'm using because it specifically guarantees the operation won't be optimized away. I'll probably be using something different in practice. – Reid Rankin Aug 15 '19 at 21:27
  • 1
    Break this while complying with the standard: `new` but no `delete`, `union` with something and don't call the destructor, `alignas(Credential) std::byte memory[sizeof(Credential)]` and use placement new with no manual destructor call, etc. – Justin Aug 15 '19 at 21:30
  • @SombreroChicken While that source indicates that the destructor is called "whenever an object's lifetime ends", the text of the standard seems to indicate that there are [exceptions](https://timsong-cpp.github.io/cppwp/basic.life#5). – Reid Rankin Aug 15 '19 at 21:32
  • @rustyx Out of scope; once the program is dead, it's the OS's problem. (You could also avoid destructor calls via a `SIGKILL`.) – Reid Rankin Aug 15 '19 at 21:38
  • 1
    I remember hearing that it's allowed to deallocate the memory in which an object lives while not calling the destructor. In particular, in one of the talks by a guy who works at Bloomberg, it was mentioned that at least one of their allocators simply destroys the entire region of memory rather than calling the destructors for each element. If you place the `Credential` in a container, make sure the container doesn't do stuff like this. – Justin Aug 15 '19 at 21:39
  • @user4581301 It would, if I could be sure that the programmer was the only one that was allowed to end an object's lifetime that way. – Reid Rankin Aug 15 '19 at 21:39
  • @Justin Yep, that would be technically legal... it would be nice to have assurance that the STL containers don't do that. – Reid Rankin Aug 15 '19 at 21:41
  • Coding errors also out of scope? Memory leaks, broken rule of three/five, etc? – rustyx Aug 15 '19 at 21:41
  • @rustyx Yes, but I should clarify that it's broken stuff by the users of the class that's out of scope, not broken stuff in the `Credential` class. (For which, you'll note, the default copy/move constructors are adequate.) – Reid Rankin Aug 15 '19 at 21:43
  • `memset_s` is not part of the current draft – Language Lawyer Aug 15 '19 at 21:48
  • @LanguageLawyer Hence [this](https://stackoverflow.com/questions/57516409#comment101500745_57516409) clarification. – Reid Rankin Aug 15 '19 at 21:50
  • Destructors are just as important as any other function. They may flush buffers to files, free other objects, and so on. So far as I know, there is no context in which code that isn't UB leaves it up to the implementation whether or not to call a destructor. – David Schwartz Aug 15 '19 at 22:16
  • @SombreroChicken you quote the behaviour of the abstract machine; according to the as-if rule any optimizations are permitted that don't result in a change in *observable behaviour* (a term which is formally defined by the standard, and excludes the contents of memory which has been deallocated) – M.M Aug 16 '19 at 02:46
  • Nobody mentioned volatile yet? – curiousguy Aug 17 '19 at 13:22
  • Related: https://stackoverflow.com/q/11637611/963864 – curiousguy Aug 17 '19 at 13:26
  • @M.M If an implementation supports separate compilation, it must also follow an **ABI** – curiousguy Aug 17 '19 at 13:27

1 Answers1

4

There are two issues involved here. One is observable effects. Destructors are allowed to have observable effects and when they do, that is a hard guarantee. A destructor can flush data to a file that would be lost if the destructor didn't run. A destructor can free objects referenced by naked pointers that would leak if the destructor didn't run. Destructors are just as important as every other function and their visible side-effects cannot magically disappear.

However, if you're concerned about non-observable effects, all bets are off. Anything a compiler can prove has no observable effects to a compliant program can be optimized away. This is why we have memset_s and unless you only use functions that define all the effects you want to rely on as observable, all bets are off.

David Schwartz
  • 179,497
  • 17
  • 214
  • 278
  • Let's say I'm a compiler and I want to, say, free a page that has only one object. I know that it does not have a trivial destructor, but that there's only one reference to it and nothing has read or dereferenced that yet. So I `memcpy()` it somewhere and update the single remaining reference to point to the new address. When the object dies its destructor will run, still seeing the same data and producing the same side-effects. Still, the key has leaked. Why can't this, or anything similar, happen? – Reid Rankin Aug 15 '19 at 22:33
  • @ReidRankin That's precisely what I'm saying *can* happen in the second paragraph.You're relying on non-observable effects in that case. – David Schwartz Aug 15 '19 at 22:53
  • That almost seems like it's C or bust for cryptographic functions. As they say on infomercials, "there's got to be a better way!" (Seriously, though, there's got to be a way to get at least C levels of functionality with C++ semantics, right?) – Reid Rankin Aug 15 '19 at 22:56
  • 4
    The same is true of C though. The compiler can copy blocks of memory if it wants. But I do agree that the issues are worse with C++. – David Schwartz Aug 15 '19 at 22:58
  • @ReidRankin C and C++ compilers almost always share a back end and these issues. But see my badly received (11 downvotes!!!) Q https://stackoverflow.com/q/11637611/963864 – curiousguy Aug 17 '19 at 13:32