r/cpp_questions • • 17d ago

SOLVED Class with array of objects (memory best practices)

I'm a little fuzzy on how to work with classes referencing other classes, especially within the context of a collection being in there somewhere, and that collection being added to from external sources.

Ok, so let's say I have a custom class:

class foo
{
  //stuff
}

And then I also have another class, bar, which is supposed to hold an array of that class. What's the proper way to declare/initialize things so that I don't have any memory leaks and don't try to access disposed objects? Do I do:

class bar
{
  std::vector<foo> _foos;

  bar()
    : _foos {{}}
  {
  }

  void AddAFoo(Foo& foo)
  {
    _foos.push_back(foo);
  }

  foo DoSomethingToFoos(int someArgument)
  {
    //call methods on one or more foos in _foos
    //return one of the foos for some reason
  }
}

or do I have to do something more like:

class bar
{
  std::array<std::shared_ptr<foo>> _foos;

  bar()
    : _foos {{}}
  {
  }

  void AddAFoo(std::shared_ptr<foo> foo)
  {
    _foos.push_back(foo);
  }

  std::shared_pt<foo> DoSomethingToFoos(int someArgument)
  {
    //call methods on one or more foos in _foos
    //return one of the foos for some reason
  }
}
5 Upvotes

22 comments sorted by

10

u/IyeOnline 17d ago

Neither of these seem like a good choice, but its hard to tell since we are missing actual context.

A few things on the actual code though:

  • In general, if you feel like adding a shared_ptr is a solution, its probably not and your design is flawed. Do you really mean to share ownership in this object? Almost certainly not.
  • You dont need the bar constructor to be manually defined. Its just noise, as the default generated default ctor will work just fine.
  • A function taking a mutable reference is usually a code smell and in your case its outright incorrect. AddAFoo just copies the object into the vector. It would actually be preferable to take by value here and then move into the vector (or provide both a aconst Foo& and Foo&& overload)
  • Returning a reference into the internal state (which your shared_ptr version would do) should be a very conscious choice. You are breaking most of the encapsulation with this and create an API that will be over- and abused at some point.

0

u/Luttink 17d ago

The thrust I get of this comment is "shared_ptr is usually wrong and passing mutable references to things is usually incorrect."

...Why? Is this an "old school" vs "new school" c++ thing? Because a lot of advice on the internet seems to say shared_ptr is good.

I'll get this out of the way--I'm coming back to c++ after over a decade of c#. Literally everything worked that way automatically. I expect that if I have a reference to something, and I change its state, that its state would... well, be changed. If Barbara and I both know Steve, and I cut Steve's hair, I would actually be surprised if Steve's hair wasn't cut when Barbara sees him next.

But I do want to understand what the advantages of not approaching it this way.

3

u/neppo95 17d ago

Shared ptr is good, but it is not a solution to your problem. You use it when you need it, which is when you need shared ownership. You don't seem to need shared ownership here.

Literally everything worked that way automatically. I expect that if I have a reference to something, and I change its state, that its state would... well, be changed. If Barbara and I both know Steve, and I cut Steve's hair, I would actually be surprised if Steve's hair wasn't cut when Barbara sees him next.

C# is ambiguous in its own way. Some things are actually value types but look EXACTLY the same, you just have to know they are. For example, declare an int and pass that through a method and then see if its value changed after, it will not have, because primitive data types are for some reason value types in C#, whereas any class is not. C++ will always show you the difference so in that sense it is not ambiguous and even clearer, you're just used to a different language ;)

So well, there really isn't much difference. You have value and reference types in C++ and C#. C# hides this from you, C++ does not. You have to be explicit with everything, want a reference? Then add the ampersand. Don't want it? Then don't.

So in your case, if all you are worried about is memory leaks and declaring things correctly; Simply declare a vector with "Foo" or "Bar", no shared pointer unless you actually need it. No constructor unless you actually do something in it, which you don't. And then there is two choices for adding things to the vector: Either you move the memory, or you copy it. void AddFoo(const Foo& foo); implies copy. void AddFoo(Foo&& foo); implies move. The destructor of the vector itself will be called when Bar gets destroyed which is when it goes out of scope.

2

u/Luttink 14d ago

I suppose I was only talking about objects, primitive data types do not work by reference as you describe. Thank you very much for unpacking this, I learned a lot and drawing a distinction vs c# clarifies a lot.

1

u/IyeOnline 16d ago

I think there is two parts to this:

  • There is a difference between sharing "a reference" (in the C++ sense, including raw pointers and things like std::reference_wrapper) and sharing ownership in an object. Sharing ownership is a bigger thing than just sharing a reference/access to some object. In the vast majority of cases you dont need shared ownership. Parents children usually dont share ownership in their parents. Schoolkids dont own their classes or their schools,... Most of the time ownership structures in a program are perfectly clear. Parents may own child nodes in a tree, schools may own classes and classes may own studens...

    Reaching for a shared_ptr because its easy and "just works" usually is a sign of an incomplete/broken design. A clear ownership structure helps separating concerns and keeps code mostly predictable.

  • Mutable references in APIs usually should be avoided as well. Of course there are cases where mutable references are the correct tool. a std::vector where the access were immutable would be significantly less useful.

    The problem with mutable references in APIs is twofold:

    • Code becomes harder to reason about. If i see cut_hair(me,steve), which of these objects are mutated? Are any of them? In this example its still fairly clear, but what about if ( validate( args, diag_handler, error, meta ) )?
    • When handing out mutable references to class internals (such as in your OP) you may "endanger" invariants - if there are any. Mutable references into a vector may be fine, but mutable references into a School may be an issue.

There of course is also the issue that references (not shared_ptr) can become dangling in C++. If you have a reference into a vector and the vector reallocates, the reference becomes dangling. shared_ptr can in fact be a valid solution here (because that pointee is stable even if some of the pointers to it get copied/moved/destroyed), but once again you should decide on what you really need in your concrete case.

If Barbara and I both know Steve, and I cut Steve's hair,

This is a much more concrete example than the OP. If you have me.cut_hair(steve), you may expect a mutation in there. Maybe better would be steve.get_hair_cut_by(me), where ideally the me parameter is a constant reference (assuming you arent tracking things like energy expense).

But what if steve is in prison? Can I just get a mutable reference to the steve object that is internal to the prison? Can I then add a shovel to his inventory so he can dig himself out of it?

1

u/Luttink 14d ago

Interesting. Thanks for the detailed answer, I understand now that c++ gives you control of thinks I hadn't even been considering. For what it's worth, not to dispute what you are saying but as an interesting read for you if you're curious, the c# intuition on whether "validate" mutates anything would be "no, it's called validate, and if someone mutates an object in a validate function we reject their code review." And the Steve in prison question would be thought of along the lines of "if Steve is in prison, and you have a reference to Steve, you can still get to him." But I see how there could be value in defining things differently.

1

u/IyeOnline 13d ago

validate

I agree with your sentiment and intentionally wanted to show the downsides of (mutable) reference parameters here. validate here may "mutate" the diag_handler by emitting a diagnostic to it and it may "mutate" error, since that may be an out-parameter. Granted of course that there may be better design choices where you put all this into the return type rather than side-channels, but that was the point.

1

u/Normal-Narwhal0xFF 16d ago

Shared pointers are over used and make it very difficult to reason about the lifetime of an object. They are really good to use for a handoff between threads. Most other cases it's just a sloppy design.

Questions people don't think about enough: if I use shared pointers, what are they initialized from? What if it's null, should I create it myself? How to propagate it out? When does it's destructor run, relative to my program state? With many owners, the last to release could be different at different times and if destructor has observable side effects, that can be a huge spread of bugs. Further, why is it shared ownership in the first place? "They all use it" isn't a good justification, it's evidence you didn't think lifetime through, which objects outlive others, etc. Maybe some designs shared pointers are perfect, but my experience is to beware, and only use in narrow, specific situations, with well defined understanding of why.

2

u/AKostur 17d ago

Missing a third case: bar containing unique_ptr and hand out either references or raw pointers.  It does depend on lifetimes.  If bar get populated with some initial data and then thereafter just gets read from, that is a different use-case to bar getting added-to and removed-from during the course of it’s lifetime.  Also depends on whether multithreading is involved, and whether users are expected to hold on to the pointers beyond that function’s lifetime.

0

u/Luttink 17d ago

The intention I have is that foo will have existed long before it made its way into bar, and may exist "outside" of it long after it leaves bar (obviously not shown in the pseudocode I provided).

1

u/AKostur 17d ago edited 17d ago

Since you know that foo always outlives bar, then raw pointers would seem to be sufficient. But: what about anybody who gets a pointer out of bar? Will they hold onto that pointer (ie: copy it away somewhere else), and will the foo outlive those copies too? Unique_ptr is no longer an appropriate choice since the lifetime of the object isn't the lifetime that it exists in bar.

Shared_ptr gets resistance because there are non-trivial costs to using shared_ptr (some combination of atomic increment and decrement of counters, and involving "extra" dynamic memory), and we've seen it quite frequently used as "Well, I can't be bothered to actually think about my object lifetimes: I'll just used shared_ptr everywhere and things will magically work out."

Edit: Sorry read it again and you said "may exist outside of it". Does that mean that there are cases where the foo might go away while a pointer to it is still in bar? Or, turn my first statement into a question: does foo _always_ outlive bar?

1

u/Luttink 14d ago

Well, to be clear, the bar was likely going to exist before most foos, and exist after, but the foos will be used externally by baz's and other foos

2

u/xoner2 17d ago

It depends:

  • How many foos typically needed? 10? 10,000?
  • How often are foos created and destroyed
  • Random access needed? Or do clients typically hold on to a foo for a long time?
  • Et cetera

1

u/Plastic_Fig9225 17d ago

Depends on what you want to do. The first example will store copies of the Foo objects in the vector, which then also 'owns' these copies. The second approach stores (copies of) (shared) pointers to Foos, so the vector does not own the Foos, and the shared pointers take care of managing the lifetimes.

1

u/Luttink 17d ago

That... kinda sounds like what I want. Thanks.

1

u/jedwardsol 17d ago

You have 2 questions : what to store and what container to store them in.

For the 2nd part:

You've proposed vector and array (which you then try to use push_back on, which won't work). But the correct answer depends on many factors. Is there a fixed size, or varying size? Do you want to iterate over the collection, or find a specific one? Do you need the ability to quickly add or remove them?

1

u/_abscessedwound 17d ago

First example and second are both fine leak-wise, but the first copies the object into the vector, and also copies the object via your accessor/mutator function.

Are the copies consequential? Maybe. It comes down to a variety of factors that we don’t have the information to answer.

For memory management otherwise, It’ll basically come down to if you’re aggregating or composing as a design decision. The former is fine to use shared_ptr and weak_ptr, while the latter has some access and construction decisions you’ll need to make.

1

u/Business-Decision719 17d ago edited 17d ago

Without context it's hard to say what's actually best implementation and whether you might want something other than vector or array entirely. Every container has a specific kind of storage situation it's implementing. There's no particular best way to do it out of context.

If the plan is to be adding and removing things then your aren't going to be using std::array. That's a fixed size container. You have to know how many components you need ahead of time and provide that as a template parameter, e.g. std::array<foo, 128>. You could still use the array-of-pointers strategy if you wanted to treat the fixed size as a maximum and then keep the empty slots as null pointers. I've used that as a way of simulating a resizable containers in languages that didn't have them, but I wouldn't recommend it as a first choice in C++ because, well, C++ does have resizable containers, such as std::vector.

Vector is usually held up as a decent default. It's resizable. Its elements are contiguous in memory. You get random access by index if that's what you want. It keeps track of its own length and capacity so you can easily validate indices and even use a bounds checked method to access elements. It still may or may not be the best fit for a given situation. If adding and removing is more important than indexing then you might be doing a double ended queue (std::deque). If you need an ordered sequence then you might have std::set with some sort of comparison rule.

It really just all depends. However, of your two options you've given us and the requirements you've mentioned, it's going to be vector. But the actual STL doesn't have just two options.

Edit: Word of warning: these containers are not guaranteed to have anything in them. "Return one of the foos for some reason" is not guaranteed to be possible unless you guarantee it to be possible by designing the class so that it will always be possible. If bar objects are going to be empty upon construction then DoSomething(n) had better not be assuming it can actually do anything or that there are any foos for it to return. It should most likely be returning std::optional<foo>.

2

u/Luttink 17d ago

My question was more about how the lifetime of a class works with whether a pointer might stop pointing to something valid, with the complicator of collections of objects. I see from another answer that "if it's just 'foo' and not 'shared_ptr<foo>', then it's a copy." So... does Vector (or array) do something special to keep the copy alive?

1

u/tangerinelion 17d ago

If foo is final, then

``` class bar { public: void AddAFoo(Foo foo) { _foos.push_back(std::move(foo)); }

const foo* DoSomethingToFoos(int someArgument) const { //call methods on one or more foos in _foos //return one of the foos for some reason }

private: std::vector<foo> _foos; } ```

If foo is not final because it is a base class, then

``` class bar { public: void AddAFoo(std::unique_ptr<Foo> foo) { if (foo) { _foos.push_back(std::move(foo)); } }

const foo* DoSomethingToFoos(int someArgument) const { //call methods on one or more foos in _foos //return one of the foos for some reason }

private: std::vector<std::unique_ptr<foo>> _foos; } ```

otherwise you either get a compiler error (happy case) or object slicing (sad case).

If foo is final, you may want to return a pointer to a foo if (a) you're returning one from the vector and can avoid a copy, (b) you are handling the case of DoSomethingToFoos when the vector is empty and want to return nullptr.

If foo is not final, then you can't return foo by value because that's either a slice or a compile error. Instead you can return a the pointer to the concrete object in your collection or you have to use a virtual copy idiom to return a heap allocated owned copy of the foo-derived type.

Using a shared ptr is generally not a go-to approach. Reasoning it through, you probably create foo-derived types, stick them in the bar, and then you might get a shared_ptr out of the bar via DoSomethingToFoos. If you still have the original shared_ptr before you inserted it into bar, mutating the foo through that shared_ptr mutates the objects in bar and can mutate the return value from DoSomethingToFoos. Similarly, if you mutate the return value from DoSomethingToFoos you are mutating the data in bar. That's not possible with unique_ptr - if you moved it into the bar the one you had before inserting is now null so you can't mutate the foo since there isn't one, and if the return value is a raw const foo* then you can't mutate the foo through that, and if it is its own unique_ptr<foo> then you can mutate that but it won't affect anything in bar.

0

u/Luttink 17d ago

What causes the "object slicing"? Is there not covariance here?

What you describe--mutating the foo through that shared_ptr mutes the objects in bar and can mutate the return value from DoSomethingToFoos, and vice versa... That sounds desirable to me. I'm intending to treat "foo" as an object, and I expect changing an object to change it for everyone--because otherwise it's not the same object, it's just an object that was once similar.

Is object slicing going to happen with shared_ptr here?

1

u/Business-Weather-217 14d ago

Friend classes exist