r/cpp_questions • u/Shevvv • 2d ago
SOLVED Unconditional exit action
Recently I started a C++ project, and while I do have some C experience, this is a new language for me, so I have doubts about a lot of the paradigms that ChatGPT swears are the way to go. Just to be clear, I write all of my code myself, architecture and implementation, and use ChatGPT as a consultant/reviewer.
I currently have the following model: a BooksReport class that stores book piles sorted by size within three groups: Uniform, Nuniform and Singles. trying to traverse a single group while popping piles at the same time was quite cumbersome, because popping invalidates iterators and there are several cases when the end of a size of piles is reached, or when the size has become empty, or the end of the group is reached... So I embedded a private Cursor class that allows me to traverse a single group within BooksReport the following way:
for (
booksReport.resetCursor(BooksReport::Group::Singles);
booksReport.cursorIsValid();
booksReport.advanceCursor()
) {
auto [size, name] = booksReport.readCursor();
if (iWantThisPilePopped(size, name))
booksReport.popCursor();
}
When popCursor() is called, Cursor is alerted of an incoming pop, to which it responds by recalculating the indices to advance to during the next advanceCursor() call. Current implementation allows no more than one popping per loop, which I hope is a fair assumption to make during a traversal. Also, as you can see, a Cursor is either valid (which it becomes upon resetCursor() or invalid (it becomes invalid by reaching the end of the group). The valid attribute not only controls the loop, if it's set to false, it also block all Cursor-related operations, such as popCursor()
However, sometimes I exit the loop prematurely. Sometimes it's a break, sometimes I return from inside the loop. Technically, I end up with a Cursor that is valid outside of the loop, which is not ideal, since then I can popCursor(), which is not the intended use. ChatGPT offers the following solution:
#include <scope>
{
auto onExit = std::scope_exit([&] {
booksReport.clearCursor();
});
for (booksReport.resetCursor(BooksReport::Group::Singles);
booksReport.cursorIsValid();
booksReport.advanceCursor()) {
if (something)
break;
if (somethingElse)
return tasks;
if (bad)
throw std::runtime_error("bad");
}
}
Is this a common paradigm? Does my situation warrant this? For now it's just a personal project. I intend to make it open source once it's finished (if anyone will see it as valuable). In an ideal world I'd add plug-in support for others, where other developers can use a limited number of API calls, including the public members of BooksReport, but I'm already tired from this side project that I'm not sure it will come to this.
EDIT: thank you all very much for the answers! As much as I'd like to move on to the next part of the project, refactoring this to be a separate object with the lifetime tied to the loop itself is indeed the most logical and simple solution!
One of these days, I'll reach the production state 😅
5
u/looncraz 2d ago edited 2d ago
Iteration is a solved problem in C++ using iterators and auto range loops.
What you want is a standalone object that references the position in the container, this allows you to permit other threads to also iterate at the same time.
You rarely modify a list during iteration, that's dangerous, you can remove one element and return immediately or you can make a second list that contains references to the items in the first list that needs to be removed. The STL also has many facilities for handling that situation.
1
u/Shevvv 2d ago edited 2d ago
Well, it becomes cumbersome when it's an iteration within a nested
std::map<size_t, std::vector<std::string>, std::std::greater<>>that has to be treated as a linear sequence. Which is the whole idea behind the
Cursorclass - it hides the logic involved in iterating this nested structure in a way that makes it convenient.But my question isn't about how to iterate. It's about how to tidy thing up upon exit from a loop, no matter how that exit occurs.
EDIT: Just saw your edit. I suppose having an external object with the lifetime within the scope of the loop is a solution I didn't think of. But this needs rewriting then. And then communication with
popFront(), a member ofBooksReport, becomes problematic.
2
u/OldAd9280 2d ago
A loop through a vector which erases elements should be of the form:
for (auto it = vec.begin(); it != vec.end();)
{
if (foo(*it)
{
it = vec.erase(it);
}
else
{
it++
}
}
For simple cases like this you can use std::erase_if https://en.cppreference.com/cpp/container/vector/erase2 or std::remove_if and vec.erase() if you don't have c++20 https://en.cppreference.com/cpp/algorithm/remove
1
u/Shevvv 2d ago
I do use this pattern for simple vectors. In this particular case, however, I'm using:
std::map<size_t, std::vector<std::string>, std::greater<>>Which is very nice an intuitive for storage and retrieval, but is a bit cumbersome for iterating
1
u/ZMeson 2d ago edited 2d ago
You can safely erase elements inside a loop if you use std::unordered_map:
https://en.cppreference.com/cpp/container/unordered_map/erase
Removes specified elements from the container. The order of the remaining elements is preserved. (This makes it possible to erase individual elements while iterating through the container.)
EDIT: You can also use std::erase_if on an unordered_map: https://en.cppreference.com/cpp/container/unordered_map/erase_if
And you can also use std::erase_if on most standard containers including std::map: https://en.cppreference.com/cpp/container/map/erase_if
2
u/Plenty-Midnight2075 2d ago
scope_exit itself is a perfectly reasonable RAII pattern, but I think here it would be fixing a symptom rather than the underlying design.
The part that feels odd to me is that the traversal state lives inside BooksReport. That means BooksReport effectively has one global cursor, which is why you now need to worry about resetting/clearing it and what state it is left in after break, return, exceptions, etc. It also makes nested or multiple simultaneous traversals awkward.
I'd make the cursor an actual object whose lifetime represents the traversal instead:
auto cursor = booksReport.cursor(BooksReport::Group::Singles);
while (cursor) {
auto [size, name] = cursor.read();
if (iWantThisPilePopped(size, name))
cursor.pop();
else
cursor.advance();
}
Then cursor simply ceases to exist on break, return, or exception because normal C++ object lifetime/RAII handles that automatically. Ideally pop() would also leave the cursor pointing at the next valid element, so the caller doesn't have to know about iterator invalidation at all.
I'd also consider making pop() an operation on the cursor rather than BooksReport::popCursor(). Then it is literally impossible to call it without possessing an active traversal object.
So yes, a scope guard is a normal tool for "this action absolutely must happen when I leave this scope", especially when wrapping C APIs or cleanup that can't naturally be represented by an object. But in this particular case you already have something with a natural lifetime: the traversal itself. I'd model that lifetime directly rather than adding another cleanup mechanism around shared cursor state.
2
u/mredding 2d ago
book piles sorted by size within three groups: Uniform, Nuniform and Singles.
It took quite a bit to realize N wasn't a variable, but means Non. Spell it out. Think about the next guy. You don't code on punch cards, you don't need to spare character counts.
trying to traverse a single group while popping piles at the same time was quite cumbersome
Pain is intuition trying to tell you something. You should listen to it rather than ignore it. You're talking about doing two different things across two different container types. Stacks and queues don't have iterators because they break the push/pop idiom.
Pick one.
I embedded a private Cursor class that allows me to traverse a single group within BooksReport
It sounds like a container of containers.
If you want to traverse a container of containers, you want to flatten the container with a view. Consider std::views::join.
Current implementation allows no more than one popping per loop, which I hope is a fair assumption
It is not. It sounds like a completely arbitrary limitation imposed for no reason.
I exit the loop prematurely. Sometimes it's a break
I don't ever recommend this one specifically. Yes, the language allows it, but it makes the loop invariant obscure or contradictory. Returning early makes perfect sense.
Is this a common paradigm?
onExit is an example of RAII, arguably the most fundamental idiom of C++. This is exactly how to solve the conundrum you cornered yourself in.
Does my situation warrant this?
I think your situation warrants a redesign that is simpler, smaller, and faster.
1
u/AutoModerator 2d ago
Your posts seem to contain unformatted code. Please make sure to format your code otherwise your post may be removed.
If you wrote your post in the "new reddit" interface, please make sure to format your code blocks by putting four spaces before each line, as the backtick-based (```) code blocks do not work on old Reddit.
I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.
1
1
u/alfps 2d ago
❞ Is this a common paradigm
Not in C++. But an iterator provided directly by the collection is a thing in Eiffel. Or was, I just sort of looked at Eiffel some decades ago.
I know one use case for that in C++, namely for a buffer gap structure. You can move the gap (which is the point of this structure) and that's an insertion and deletion position so it's effectively an iterator in the structure.
As others have already explained, idiomatic C++ for deletion while iterating over items, is to update the iterator with the iterator value you get from erase or whatever deletion operation, in that case instead of ++it.
1
u/mredding 1d ago
I should also add I realize you're likely using that enum as an ad-hoc type system - instead of using RTTI, you're storing an enum associated with a type, and that's deciding subsequent behavior, like casting or pointer use.
Instead, you should be using the type system you have:
struct uniform {};
struct non_uniform {};
struct singles{};
using book_report = std::variant<std::monostate, uniform, non_uniform, singles>;
This will give you much stronger type safety and make your code simpler.
1
u/Shevvv 1d ago edited 1d ago
Thanks for the reply! No, the use of that enum is slightly different. A single
BookReportstores three sets of data:``` public: enum class Group { Uniform, Nuniform, Singles };
private: using SizesMap = std::map<size_t, std::vector<std::string>, std::greater<>>; size_t total = 0; size_t uniformTotal = 0; SizesMap uniformSizes; size_t nuniformTotal = 0; SizesMap nuniformSizes; size_t singlesTotal = 0; SizesMap singlesSizes; std::pair<const size_t &, const SizesMap &> selectGroup(Group booksGroup) const; std::pair<size_t &, SizesMap &> selectGroup(Group booksGroup); ```
Using
selectGroup()allows me to choose the right pair ofsize_tandSizesMapto work with within the algorithm.EDIT: I actually do use
std::variant<T, U>inside a custom classPilewhich behaves like a folder within a tree structure that can contain either only folders (otherPileobjects) or onlyBookobjects. I have indeed anenumthat basically is used for querying aPileobject if it's storingPile,BookorEmpty(the container within is empty and as such thePileobject accepts both otherPileobjects andBookobjects). But I'm not exactly sure how to rewrite it according to what you suggest, or whether I should because I spent a lot of time designing an API for that class that is convenient to use (for me; that class is not meant to ever be exposed for external plugins). But that's a very different module of the project not involved in the algorithm I'm currently writing.1
u/mredding 1d ago
size_t xTotal = 0; SizesMap xSizes;So then THIS is a type. It just needs a name and some structure. You repeat this 3x. Perhaps you should then write:
// This is a C idiom that might be useful here; since you're using the // enum as an index of integers, the weaker C enum is more appropriate, // so you're not casting to the underlying type all the time. enum group_indexes { begin, uniform = begin, non_uniform, singles, end, count = end }; std::array<count, sizes_type> sizes;Now you can index:
return sizes[uniform];Or:
return sizes[0];Or you can loop:
for(auto index = begin; index < end; fn(sizes[index++]));And because the enum is compile-time, the compiler can unroll that loop.
Or you can jump:
switch(index) { case uniform: case non_uniform: case singles: default: break; }But I still suggest you make 3 DIFFERENT types, because while the data is structually the same,
uniformvs.non_uniformvs.singlesdoesn't sound like this data can be interchanged - it would likely be an error thatsinglesdata winds up innon_uniformdata, no? The best way to prevent that is to use the type system to make it tangibly impossible, reducing to a compiler error. There is still a semantic difference and the compiler can enforce that.Curry-Howard correspondence tells us that there are similarities between coding and proof writing. Your statements are posits, your source code is a theory, the compiler is the solver, and the generated program is the proof. We want elegant proofs, which means we need elegant theories. By leveraging the type system, you can make elegant theories by building compounding expressiveness - simpler words that allow you to create more complex words and statements about the theory. The theory becomes self-referential and internally consistent. I mean - just read a proof, they invent a lot of their own little universe, and then demonstrate themselves in terms of it. We're doing the same thing.
As for the rest of the code that processes this data, because it's generic across 3 different types, you implement that in terms of templates. You write that code once and let the compiler generate the appropriate machine code.
This template code gives you great flexability - you can specialize a template around any one of these types, if there are unique or optimal considerations for that type. You can also consider how you might factor type-independent code OUT of the template to reduce object bloat, so the templates become thin wrappers that enforce type safety.
And if the underlying data can interchange, you can do so explicitly.
11
u/IyeOnline 2d ago
I would strongly advise against this. You are designing an API from zero, so your design has the chance of being safe by construction. If your cursor is an internal state of the
BooksReportclass itself, but still must be manually managed from the outside, something is wrong.Instead, the cursor should actually be the object that contains the information, instead of holding this state into
class BookReportitself.One common paradigm here would be to have an accessor that returns a range (an iterable object), which holds both the information for iteration as well as the mutating API. This means that you cannot perform these mutating operations without having such an object and that any "cleanup" automatically happens when the object is destroyed.