CodeGym /Courses /C++ SELF /RAII in the Standard Library: streams, containers

RAII in the Standard Library: streams, containers

C++ SELF
Level 42, Lesson 1
Available

1. Introduction

If when doing manual new/delete you felt like a bomb disposal technician handed two wires and told you "be careful", then the C++ standard library is the moment when you finally get a proper factory connector with a latch. The idea of RAII (Resource Acquisition Is Initialization) in the standard library is not a "feature" but a fundamental culture: most types there are designed to acquire a resource when an object is created and reliably release it in the destructor. And this works not only for memory — a "resource" can be a file, a buffer, a lock, a connection, a handle, etc.

It's important to get the right feeling here: RAII is not "a way to write destructors for the sake of destructors", but a way to make your program less nervous. You write code by intent (what to do), not by ritual (in every branch remember to call delete, close(), unlock(), and so on).

2. RAII in containers: std::string and std::vector

When you first studied std::string and std::vector, you most likely thought of them as a "convenient string" and a "convenient array". Now we'll look at the same types through the lens of RAII: they are ready-made owners of dynamic memory — owners in the good sense, "boring". They can allocate memory, grow, free memory and don't force you to write delete[] in the corners of your program. Boring means reliable (in programming this is a compliment).

"Memory as a resource": who frees it and when

std::vector<int> stores a dynamic buffer (on the heap) inside, where it places elements. When the vector goes out of scope its destructor is called — and the buffer is freed automatically. This is exactly the same idea as our homemade RAII owner from the previous lecture, only implemented professionally and tested by millions of programmers who also sometimes forget to eat and to call delete[].

A mini-example "memory frees itself" looks banal — and that's good:

#include <vector>

void f() {
    std::vector<int> v{1, 2, 3};
    v.push_back(4);
} // v is destroyed -> memory is freed automatically

An important psychological point: you don't see delete[], but it's "kind of there" — just hidden inside the vector's destructor. So you're still working with the heap, you just no longer have to be responsible for every byte.

Early return and automatic freeing

One of the nastiest causes of leaks is an "early exit". You've already seen this with examples using new int{...}. With containers you get RAII insurance automatically: when the function exits the container will be destroyed, so the resource will be freed.

#include <iostream>
#include <vector>

int sum_if_ok(bool ok) {
    std::vector<int> data{10, 20, 30};

    if (!ok) {
        return 0; // data will still be destroyed correctly
    }

    return data[0] + data[1] + data[2];
}

int main() {
    std::cout << sum_if_ok(false) << '\n'; // 0
    std::cout << sum_if_ok(true) << '\n';  // 60
}

There is no "memory stone" with the inscription "don't forget to clean up". Destruction is tied to the object's lifetime, not to your attentiveness.

Copying without double-free

In the lecture about double-free we saw the problem: if two pointers point to the same address and both call delete, trouble will follow. With std::vector and std::string that does not happen in normal use, because copying them is value-copying. Each copy has its own buffer, and each destructor frees its own resource.

Let's compare the idea (without running UB code).

Unsafe thought (copying a pointer = two "owners" of the same address):

int main() {
    int* p = new int{1};
    int* q = p;     // q and p point to the same address (two "owners" in a beginner's head)

    delete p;
    // delete q;    // UB: double-free (do not execute)
    q = nullptr;
}

But with vector copying behaves sensibly:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> a{1, 2, 3};
    std::vector<int> b = a;   // copy of values (not a copy of the buffer address)

    b[0] = 100;

    std::cout << a[0] << '\n'; // 1
    std::cout << b[0] << '\n'; // 100
}

Yes, copying a vector can be expensive (elements are copied). But in the context of RAII it's a fair price for clear ownership semantics. We'll discuss ways to avoid unnecessary copies later, but right now the important thing is: copying containers does not turn your program into a minefield.

Mini-diagram: how RAII "holds" container memory

flowchart TD
    A["Created vector/string"] --> B["Memory allocated inside (heap)"]
    B --> C["We work with data"]
    C --> D["Exit scope (}) or return"]
    D --> E["vector/string destructor"]
    E --> F["Memory freed automatically"]

The point of the diagram is that the resource (memory) is tied to the object (the container), not to a set of manual actions scattered across the function.

3. RAII in streams: std::stringstream and file streams

The word "streams" often causes strange associations for beginners: some think of "threads of execution", some of "flows of water" (which is closer to the truth than it seems). In the standard library a stream is an object through which input/output "flows", and the object usually owns some resource: a buffer, a file descriptor, formatting state. And that's exactly why streams are an excellent example of RAII: they correctly release what they own when they go out of scope.

std::stringstream: buffer as a resource

You've met std::stringstream in parsing topics: it keeps a string inside, allows writing to it with <<, then reading with >> or getting the result via .str(). Inside it has a buffer (memory), and that buffer is also freed automatically when the stringstream is destroyed.

Practical benefit: it's convenient to build strings for logs/messages without concatenating everything manually with +.

#include <iostream>
#include <sstream>
#include <string>

std::string make_log_line(int id, const std::string& text) {
    std::stringstream ss;
    ss << "task#" << id << ": " << text;
    return ss.str();
}

int main() {
    std::cout << make_log_line(7, "buy milk") << '\n'; // task#7: buy milk
}

Note: we return std::string by value, and that's fine. String is an owning type, and its memory will also be managed automatically.

File streams: "open — and it will close itself"

Full-featured file handling will be covered in a separate session, so here we take exactly one idea: a file stream object (for example, std::ofstream or std::ifstream) usually closes the file in its destructor. So even if you forget to call .close(), the stream will release the resource when it goes out of scope.

This is one of the reasons why in C++ people like to "wrap resources in objects": closing a file in one place is easier than remembering close() in every branch.

Mini-demonstration (without delving into error handling):

#include <fstream>

void write_demo() {
    std::ofstream out("demo.txt");
    out << "Hello!\n";
} // out is destroyed -> file is closed automatically

Even if you won't write such code in practice for exercises yet, it's important to have this fixed in your head: streams are RAII objects, they "hold a resource" and "release the resource" by themselves.

4. RAII in locks: std::mutex and std::lock_guard

There is a funny paradox with locks: as soon as you start explaining them "properly", beginners want to run away. But if you don't explain them at all — beginners later write code that sometimes works and sometimes... behaves on its own. So we'll do exactly what is needed for the topic of RAII: we'll look at a lock as a resource and see how RAII prevents forgotten unlock().

Note: we are not building multithreaded programs now and not discussing data races. We are only interested in the mechanics of "acquire resource — release resource reliably".

Why manual lock()/unlock() easily breaks

Imagine you have a mutex and you lock it manually, then somewhere you do an early exit. This is a very human error: you added return to improve readability and forgot that you now also need to call unlock().

#include <mutex>

std::mutex g_m;

void bad(bool ok) {
    g_m.lock();

    if (!ok) {
        return; // the mutex will remain locked (logical catastrophe)
    }

    g_m.unlock();
}

Even without threads it's visible: this function has a path where unlock() is not executed. In a multithreaded world this becomes a hang that "sometimes happens", "doesn't reproduce on the team-lead's machine" and "for some reason always on Friday evening".

std::lock_guard: "locked in constructor, unlocked in destructor"

std::lock_guard is a small guard object. It acquires the mutex in the constructor (locks), and releases it in the destructor (unlocks). This is the literal RAII model: the resource is the "right to hold the lock".

#include <mutex>

std::mutex g_m;

void good(bool ok) {
    std::lock_guard<std::mutex> guard(g_m);

    if (!ok) {
        return; // guard is destroyed -> mutex is unlocked automatically
    }

    // ... critical section ...
}

The idea is so fundamental that discussions and documentation around the standard library regularly surface requirements and formulations about lock types and their interfaces.

Critical section boundaries are given by {}

It's useful to mentally stop thinking "I called lock/unlock" and start thinking "I created an object guard — the section started; guard was destroyed — the section ended". Then the boundaries of the protected area become as visible as code indentation.

Here is a neat pattern using a separate block:

#include <iostream>
#include <mutex>

std::mutex g_out;

void print_line(const std::string& s) {
    {
        std::lock_guard<std::mutex> guard(g_out);
        std::cout << s << '\n';
    } // guard destroyed -> mutex released
}

Yes, in single-threaded code this looks like "putting on a helmet to go to the store for bread". But in a lecture about RAII the important principle is: if resource release is tied to a destructor, you won't forget to release it on early return, block exit, or any code structure.

5. Mini-example: TaskBook without new/delete, but with RAII

Now we'll assemble a small fragment of our hypothetical console application "TaskBook" (a task list) that we could develop throughout the course: we store tasks in memory, read commands as a line, parse it with stringstream, and print results. In this lecture we don't add "new functionality for the sake of it", we show that almost everything here is already RAII, and manual resource management is simply unnecessary.

Data model: tasks in std::vector, text in std::string

#include <string>
#include <vector>

struct Task {
    int id{};
    std::string text;
};

using TaskList = std::vector<Task>;

No Task*, no new Task[]. Memory for the task list will grow automatically inside the vector, and strings will store text inside string.

Adding a task: the container manages memory itself

#include <string>
#include <vector>

int add_task(std::vector<Task>& tasks, const std::string& text) {
    int new_id = static_cast<int>(tasks.size()) + 1;
    tasks.push_back(Task{new_id, text});
    return new_id;
}

It's important not that this is an "ideal id generation" (it's not), but that we don't think about freeing memory at all: tasks owns its elements.

Log line via std::stringstream

#include <sstream>
#include <string>

std::string format_added(int id, const std::string& text) {
    std::stringstream ss;
    ss << "Added task #" << id << ": " << text;
    return ss.str();
}

ss will be destroyed — the buffer freed. We return std::string — it manages its own memory.

Printing with a "guard": lock_guard as RAII not for memory

Even if we don't have threads yet, let's write the printing function as if it might be called from different places (or different threads later). This is good style: protection of the resource (the console) is in one place.

#include <iostream>
#include <mutex>
#include <string>

std::mutex g_cout_mutex;

void safe_println(const std::string& s) {
    std::lock_guard<std::mutex> guard(g_cout_mutex);
    std::cout << s << '\n';
}

And here is how this might look together:

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<Task> tasks;

    int id = add_task(tasks, "learn RAII");
    safe_println(format_added(id, "learn RAII")); // Added task #1: learn RAII
}

The overall idea: we assembled a small piece of a program where resources (container memory, string memory, stringstream buffer, lock) are freed automatically because they are wrapped in objects with destructors.

6. Common mistakes when using RAII types from the standard library

Mistake #1: thinking RAII = "nothing can go wrong".
RAII guarantees resource release when an object is destroyed, but it doesn't guarantee that you use the object correctly. For example, std::vector won't protect you from the logical error "put the element in the wrong place", and a stream won't protect you from "reading using the wrong format". RAII is a seatbelt, not an autopilot.

Mistake #2: keeping long-lived pointers/references to internals of vector/string.
A common trap: take Task* p = &tasks[0]; and store it somewhere, then call tasks.push_back(). vector may reallocate and your p will point to nowhere. This doesn't invalidate RAII, it's just another category of problems — lifetime of bindings to container elements. So in practice people either avoid keeping such addresses for long, or keep indices/identifiers instead.

Mistake #3: "manual management on top of RAII": extra close(), unlock(), delete[] in inappropriate places.
Sometimes a programmer gets used to "just in case close everything manually" and starts calling unlock() where a lock_guard should do it, or manually closes a stream in the middle of a function and then wonders why further writes don't work. If you chose an RAII object, let it do its job: resource release should be tied to scope, otherwise you bring back the old risks you were trying to avoid.

Mistake #4: too broad a critical section when using lock_guard.
Another extreme: create a lock_guard at the start of a function and keep the mutex locked "just in case" until the end, including heavy computations or I/O. This isn't a compilation error, but an architectural mistake. RAII is convenient because the section boundaries are given by the block {} — use this to lock exactly the needed area, not more.

Mistake #5: using new[] where vector/string already exist, justifying it with "it's faster/simpler".
In practice for a beginner new[] is almost always harder: you need to remember delete[], you can't copy it "by value", it's easy to lose the address and cause a leak. std::vector and std::string solve these everyday problems. If you cannot very clearly explain why you need a manual heap, then you probably don't need it yet.

1
Task
C++ SELF, level 42, lesson 1
Locked
Batch inspection
Batch inspection
1
Task
C++ SELF, level 42, lesson 1
Locked
Single log line
Single log line
1
Task
C++ SELF, level 42, lesson 1
Locked
Command buffer
Command buffer
1
Task
C++ SELF, level 42, lesson 1
Locked
Printing Under a Lock
Printing Under a Lock
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION