C++ Logo

std-proposals

Advanced search

Re: [std-proposals] Delete...why not a parameter by reference?!

From: organicoman <organicoman_at_[hidden]>
Date: Mon, 25 Aug 2025 08:39:54 +0100
Thank you very much for this extremely detailed explanation of your mindset regarding this proposal. I appreciate it very much. Seriously.I decided to not answer anything after my last reply, but your effort and the time spent writing it, just made me ashamed if I will not.Let me try to answer all your concerns.Please don't take it as a rant but a honest discussion.First, I need to attract your attention that I forgot to add "assign nullptr to the deleted pointer" in the Proposal section, but you already assumed that in your discussion. ThanksAll what you brought as examples revolve around one decisive point.You are separating the proposal from its guideline and discussing the impact of each standalone.While in reality, the guideline comes hand in hand with the proposal. Think of it like an inspection of the side effects of the delete operator.So wherever you have a function that takes a pointer and invokes the 'delete' expression inside its code, it is preferred that the pointer is taken by reference. For example:void foo(T* ptr) { delete ptr;}Should bevoid foo(T* &ptr) { delete ptr; // after detetion ptr = nullptr}This way, i can inspect the side effects of delete from outside the callee, even if i don't know its source code.For your other example:struct Foo { int* buffer; size_t length;};unsigned find(std::function<bool(int)> predicate, int *haystack, size_t len) {  unsigned index = 0;  while (index<len && !predicate(*haystack++)) ++index;  return index;}Unsigned foundIdx = find(somePredicate, myFoo.buffer, myFoo.length); There is no delete inside the code, so as an implemeter I will express it by taking the pointer by copy not by reference....it's an indication to the caller that the code doesn't contain any 'delete' expression. All the examples that followed have the same pattern. Let's assume in a crazy scenario where a function does two things, find and delete, for example (of course this is bad code because it breaks the single responsibility principle) but let imagine it for argument sake.unsigned find_and_free(std::function<bool(int)> predicate, int* &haystack, size_t len) { // int* copy = haystack; unsigned index = 0; while (index<len && !predicate(*haystack++)) ++index; if(index!=len) delete haystack;///impossible unless you keep a copy of the original pointer return index;}As the comment above note it, there is no way to use delete on the original pointer unless you keep a copy of it. So by design, you will not change the original pointer. Do you get me?As for your following example:struct Foo { int* data() { return buffer; } size_t length() { return _length; }private: int* buffer; size_t _length;};size_t foundIdx = find(somePredicate, myFoo.data(), myFoo.length()); // Compile failsThat's wonderful to have the compilation fail, because if inside the 'find' there is a delete (indicated to me by the reference to pointer) I will definitely know that calling 'find' is fishy. And I need to exercise more precaution after 'find' returns.So I will change my code like so:size_t foundIdx = find(somePredicate, myFoo.buffer, myFoo.length()); if(myFoo.data() == nullptr ) throw "owning callee";So I caught a bug in the 'find' implementation.There is no way to guess that if I pass by copy.Always hand in hand, the proposed delete and the reference guideline.As for the syntactic sugar you've mentioned, this one:unsigned find(int needle, int *haystack, size_t len)To`unsigned find(int needle, int **haystack, size_t len)In one of your previous reply, you mentioned that if the pointer is reassigned without using it, the compiler can optimize it out and dangle the references.So I started investigating. And I found that even for optimization level 0, the reference to pointer will collapse to the pointer itself.The compiler is smart enough to tell that there is no usage of the address of the pointer, but only of its value, so it collapse all reference to the pointer itself.So no performance drop, no double indirection and no extra pointer management.Also I found the answer to your observation, so by the time the pointer is reassigned, all its previous references already ended their lifetime. So no dangling reference. One case which was difficult to reason about is threading(async ref), because I don't have a delete operator that behaves as my proposal. Now for this case:struct Type1 { Type2 *other;};Type1 *object1 = …;delete object2->other;If destruction of `other` causes `object1` to be destroyed (this is entirely plausible in refcounted object graphs scenarios), the post deletion assignment of nullptr to `object2->other` is a now a use after free.Let's analyze it step by step:delete object2->other; Calls --> ~object1(); Calls ---> delete this->other; // of Type2 Calls --> ~object2(); Calls ---> delete this->other; // of Type1BOOM, cyclic dependency before even i return from the first delete call.Assigning nullptr to the first delete operand never happens.So all in all, the proposal and its guideline when used hand in hand, and understood as it should, allows to detect so many bugs.As for the cost of the proposal:Given void Foo(T* p){ // delete is somewhere here and not necessarily on p }When changed tovoid Foo(T* &p){}1- as a caller of 'Foo' i will expect a delete inside the code2- as a caller of 'Foo' i can inspect the side effects on my pointer i passed, or simply do nothing since I'm protected by delete assigning nullptr.3- as an implemeter of 'Foo' I'm conveying the idea that there is a delete inside, even if it is not on the pointer argument itself.4- as an implemeter of 'Foo' if I promise to the caller that I will not own the pointer, yet, the caller finds it nullptr, then please report that bug.5- as a implemeter of 'Foo', if by any means I change the value of the pointer, yet I don't delete it, then I will get the bug explode at my face before it reaches my clients.Consider this expl:SomeType* find(T* &p, size_t len, Pred pre){ SomeType* pt = new SomeType; size_t idx = 0; While (index<len && !Pred(*p++)) ++idx; if(idx != len) return pt; else { delete p; // mistake pt not p return pt; }}This code will explode in my face before even I ship it to my clients, since p doesn't point to anything allocated before.All in all, you need to add one character (&) to any function that has a 'delete' expression inside, update its signature in the header files and upload it to the clients.Now how to change delete implementation without breaking ABI, that's out of my expertise, but I feel it is doable.I hope that i cleared some of the confusion around the proposal, if not then, I will try to write a document as per your request (but I'm a bit lazy :b )
-------- Original message --------From: Oliver Hunt <oliver_at_[hidden]> Date: 8/25/25 6:02 AM (GMT+01:00) To: organicoman <organicoman_at_[hidden]> Cc: Julien Villemure-Fréchette <julien.villemure_at_[hidden]>, Simon Schröder <dr.simon.schroeder_at_[hidden]>, David Brown <david.brown_at_[hidden]>, Ville Voutilainen <ville.voutilainen_at_[hidden]>, std-proposals_at_[hidden] Subject: Re: [std-proposals] Delete...why not a parameter by reference?! On Aug 24, 2025, at 7:13 PM, organicoman <organicoman_at_[hidden]> wrote:Hi,I'm really surprised about how it is difficult to some people to understand this proposal. This will be my last take.------------Intro:C++ as language guarantee backward compatibility and support for very old code. This has been said, it means that there is so many lines of code, out there, still using raw pointers. For those code bases, I want to propose a way to catch bugs with very minimal change.Agreement:1- I'm not trying to downgrade the usage of smart pointers compain, so if there is any mean to switch to smart pointers, then go for it.2- I'm not talking about containers, or how to store references.Take the proposal as is, no extrapolation.The problem:The problem is illustrated with the following code snippet. 1. {2. T* ptr = new T{....}; // T well defined type3. Foo(ptr); // opaque callee4. // this line: see discussion below5. }Explanation:On line 2, I allocate a memory resource to store a variable of type T. T is a type defined in previous code.On line 3, I call a function 'Foo', which has the following signature:void Foo(T*);This function comes from a closed source code, so there is no way that I can analyze its code.But, when I read its documentation, I find the following scenario (each scenario will be discussed separately)1- case: the documentation promises that the consumed pointer will not be deleted (not owning function)******* but the function has a bug*******As a user of that function, I take that doc as true, and in line 4 above, I can follow up with this code.4. delete ptr; // (A)Or4. *ptr = T{...}; // (B)The buggy function in actuality, does break its contract by mistake and free the resource owned by 'ptr'That is one way that happens, but more commonly the function you call reenters your code, which then invalidates the pointer. Again, straightline errors are easy to diagnose, but...Because of that, I can fall in 2 bugs, extremely difficult to catch.(A) double free(B) use after free<snip>The proposal:To help detect this kind of mistakes, I propose the following correction to the delete expression signature Previous signature:void delete(void*);Become:void delete (void* &);… if we make this absolutely trivial, your proposal changes literally nothing in your own example, so first we’ll define foo:void foo(T* ptr) { delete ptr;}Hypothetically, the delete expression writes null to ptr - again there is literally no reason to touch the definition or resolution rules for operator delete, so the code is semantically identical to what today would be written as:void foo(T *ptr) { delete ptr; ptr = nullptr;}Now we go to your example codeT* ptr = new Tfoo(ptr)/* ptr is now dangling */Your two next steps are still broken:delete ptrIs still a double free*ptr Is still a use after freeAlso to help this change takes a full effect on catching the 3 categories of bugs above, I like to add the following guide line with the proposal:It would be optional, but extremely useful, to make any callee take the argument per a reference to pointer, so if your callee signature is as follow:RetType fnName(T*); Change it to:RetType fnName(T* &);This means developers have to make wide ranging changes to there code, that can silently fail if they don’t make the changes at the right places, even if they do go through and change every function definition, it results in broken code, e.g.struct Foo { int* buffer; size_t length;};unsigned find(std::function<bool(int)> predicate, int *haystack, size_t len) { unsigned index = 0; while (index<len && !predicate(*i++)) ++index; return index;}Unsigned foundIdx = find(somePredicate, myFoo.buffer, myFoo.length); Now the author adopts your proposed stylistic change:unsigned find(std::function<bool(int)> predicate, int *&haystack, size_t len) { unsigned index = 0; while (index<len && !predicate(*i++)) ++index; return index;}NowUnsigned foundIdx = find(somePredicate, myFoo.buffer, myFoo.length); Produces an OoB access bug.It also breaks for code that does have abstraction:struct Foo { int* data() { return buffer; } size_t length() { return _length; }private: int* buffer; size_t _length;};size_t foundIdx = find(somePredicate, myFoo.data(), myFoo.length()); // Compile failsSimilarlyunsigned foundIdx = find(somePredicate, myFoo.buffer + start, myFoo.length); // Compile failsAt the same time while we use the proposed version:unsigned find(int needle, int *&haystack, size_t len) {You have not done anything to ensure the lifetime of the original pointer - let’s remove the syntactic sugar for a moment, you’ve changed us from unsigned find(int needle, int *haystack, size_t len)To`unsigned find(int needle, int **haystack, size_t len)So now we have two pointers that need to be kept alive, and need their lifetimes tracked. Let’s imagine that the predicate is non-trivial: hypothetically there’s a std::vector<Foo> and the predicate modifies that. Your proposal has done nothing to stop or detect that, but the impact is that now in addition to the developer needing to be sure the original `int*` is not destroyed, but neither is the `int**`.This is what I have been trying to point out: your proposal rests entirely upon the assumption that adding indirection does not immediately result in increasing the number of object lifetime errors that can be triggered through a single evaluation path. The only place it makes an improvement, if at all, is if you have a single allocation point, a single deallocation point, and no logic invoking further dynamic allocation or deallocation within that stretch.This part of the proposal also does not provide an explanation of how subobject pointers work either.Finally I believe this proposal will open the potential to break deletion of cyclic/self referential structures that would previously be safe, consider:struct Type1 { Type2 *other;};Type1 *object1 = …;delete object2->other;If destruction of `other` causes `object1` to be destroyed (this is entirely plausible in refcounted object graphs scenarios), the post deletion assignment of nullptr to `object2->other` is a now a use after free.So I’m not sure that this change is sound even ignoring the API changes it requires to achieve its stated safety properties.Now as we get into this last part: It is not possible to talk about this proposal without including the requirement for widespread API changes to move from pointers to unprotected indirect pointers, as the proposal immediately deflects to this once anything trivially non-local is involved.Proposal cost:Changing the delete expression signature, won't affect the calling code.We can drop this, it is absolutely unnecessary for the ABI or API of operator delete or operator delete[] to change at all. There is literally no reason to do this, it makes the proposal vastly more complex, and simultaneously limits how widely useable it is. The behavior you are trying to get via this API change is trivially met by saying “if the subject of a delete expression is an lvalue, after the call to the resolved operator delete, it shall be set to nullptr” (or something to this effect). This immediately means you are not blocked on every allocator implementing support for a new abi, and you don’t run into problems with defining resolution priority, and similar.So there is no source code change, no grep and modify or any type of code breakage.No. Your proposal has made claims of security benefits, but those benefits require significant adoption work, if your proposal wishes to include those benefits, then the proposal costs need to include those adoption costs.The costs for this proposal includes the work by developers and libraries to update all of their interfaces to take pointers by reference, which is both a large undertaking, an ABI break, and an API break. The errors from this proposal vary from silently missing a reference at one location thus losing the “benefit” of your proposal, through to breaking existing code as they are no longer changing local copies of pointers.The costs also include the performance penalties of these changes (indirection, aliasing, etc).Following the guidline suggested above, will affect only the header files, included by the consuming source code.No, you’ve changed the requirements for functions to take references to pointers, so the headers and implementations need to be updated.ABI wise, hopefully there is a way, at compilers implementers level, to not break the ABI, this is based on the assumption that, previously the delete expression has undefined behaviour when passed a nullptr, but nowadays it handles the nullptr correctly, this change was done without breaking any ABI.If done once, I hope that It can be done twice. No? You cannot change that API and expected that to not be an ABI break.I feel there is a fundamental misunderstanding about how delete works.It is not magic - the compiler looks at the type being deleted, resolves the appropriate operator delete for the type and context, and then calls it. You want to change the function being called. If you do that the ABI has changed. Every existing allocator library needs to be updated to support it.You also need to define the resolution precedence and prioritisation.ABI and API wise the security properties your are claiming are dependent on the changes to pointer reference based APIs. That’s both a significant amount of work for the implementers of libraries, but also application developers. At the same time these changes are enormously error prone, and have significant potentially to negatively impact performance, both through direct costs from additional indirection, but also the introduction of such indirection means significantly increased aliasing hazards impacting the ability for compilers to reason about local code (because definitionally this changes makes previously local state changes impact external state) - this is by design, as the changes required to permitT* ptr = …;foo(ptr);// ptr is now nullAlso permit// ptr is not a different objectAnd the abstract machine does not provide any mechanism for that to not be the case.Conclusion:The proposal doesn't bash actual or future replacement for using raw pointers, but to reach the top floor of security, we need to climbe the stairs, one stair a time.This is not a conclusion, it’s a vacuous statement that says nothing at all about your proposal, and I’m choosing to believe is not intentionally patronizing. No one in the committee believes there is a single step to perfect safety, that’s why there are multiple proposals aiming to move forward on multiple axes.From what you have written here* This proposal tries to make a much larger change to the language than is needed for the desired behavioral change;* The security benefits of just that change alone are dubious at best;* The proposal's broader security claims require significant source changes, breaking API and ABI. These source changes are also error prone, introducing new lifetime, aliasing, and correctness hazards, and the costs of this are not discussed;* Existing language features already provide the same or better guarantees and semantics, with similar source change overhead for adoption, but without the additional hazards from this proposal.It is perhaps better to ignore the behavior changes to operator delete, and consider the intended semantics of the T* -> T**/T*& change recommended as being the actual desired semantics of this proposal. The idea there is to have pointer references that are all nullified when the target object is deallocated, ie. A weak reference. For such desired behavior std::weak_ptr already exists as well.If you really want to go forward with this, I would recommend you read prior papers, especially those discussing changes to the semantics of new and delete, and writing a formal paper that covers the same material and issues, and specify *exactly* what changes you are proposing - language, library, code, etc, and what security properties come from that change, and accurately describe the costs and hazards, which you certainly did not do here. i.e if you do not want to discuss the difficulties and costs of migrating code from pointers to pointer references, you can’t include the results of those changes in your potential benefits. It’s also worth considering if those are even relevant: there is nothing in those changes the depends on your proposed change to delete.I am not asking for another summary document as you produced here, I am asking for a detailed and researched paper, that may not be as complete as a full committee paper, but certainly a document that is clearly moving in that direction.—Oliver

Received on 2025-08-25 07:40:09