Today we begin learning about the main feature of C++, the STL. In fact, some readers have previously asked me when I would write about STL. However, since I am learning C++ while writing for this public account, I am not a C++ veteran. I can only organize and document what I have learned to a certain extent. Moreover, the content of STL is vast, so it cannot be covered all at once. It is better to proceed step by step.
Without further ado, let’s first understand the basic concepts of STL.
What is STL? Why does C++ introduce STL?
In simple terms, STL (Standard Template Library) is the core part of the C++ standard library. It is based on template technology and provides a set of general, reusable data structures (such as arrays, linked lists, stacks) and algorithms (such as sorting, searching). You can think of it as a “pre-fabricated component library for programmers”—there’s no need to start from scratch to write linked lists or dynamic arrays; you can just use the ready-made ones.
The core idea of STL is “generic programming”: regardless of whether you store int, string, or custom structures, the same container (like vector) can hold them, and the same algorithm (like sort) can process them. This universality greatly increases code reusability.
The introduction of STL in C++ essentially solves the problem of “reinventing the wheel”.
Without STL, C developers often face an awkward problem: each project has to implement basic data structures on its own. For example, Team A writes a linked list, and Team B, for flexibility, rewrites one that is similar but with different code, leading to extremely high maintenance costs.
After the introduction of STL in C++, the benefits are obvious:
- Improved development efficiency: No need to manually write basic data structures, allowing focus on business logic;
- Reduced bug rates: STL is a standard implementation validated by countless projects, more stable than self-written code;
- More unified code: Everyone in the team uses the same containers and algorithms, facilitating smoother collaboration;
- Support for generics: For example, a vector can store both int and custom objects without writing a separate code for each type.
The core of STL: Six components with clear division of labor
STL consists of six major components that work together to form a complete toolchain. There’s no need to memorize them; just have a general impression:
- Containers: “Boxes” that store data, providing various data structures, such as the most commonly used vector (dynamic array), array (fixed-size array), list (linked list), map (key-value pairs), etc.;
- Algorithms: “Tools” that operate on data within containers, such as sort (sorting), find (searching), copy (copying), etc.;
- Iterators: “Bridges” connecting containers and algorithms, similar to pointers, used to traverse elements in containers;
- Functors: Objects similar to functions, used to pass “custom rules” to algorithms (for example, specifying ascending or descending order during sorting);
- Adapters: “Interfaces” that modify containers or algorithms, such as changing a vector to a stack;
- Allocators: Responsible for “memory management” of containers, such as memory allocation and release for vectors, generally not requiring manual operation.
We will start learning STL fromcontainers, specifically the two most basic and commonly used: array and vector.
Array: The “Safe Version” of Fixed-Size Arrays
Traditional arrays have a drawback: they lack boundary checks, and accessing out of bounds may cause the program to crash, and there’s no way to directly obtain the array length. The array class introduced in C++11 can be regarded as an “enhanced version of native arrays”. It encapsulates native arrays through template classes, maintaining the performance advantages of stack memory storage and compile-time fixed size while injecting the safety features and convenient interfaces of STL containers.
Creating and Initializing an Array
An array must specifyelement type andcompile-time fixed size, with the syntax being<span><span>std::array<T, N></span></span>:
#include <array> // Must include the array class header file#include <iostream>int main(){ // Method 1: Complete list initialization (C++11 and later) std::array<int, 5> arr1 = {10, 20, 30, 40, 50}; // Size 5, elements are 10-50 // Method 2: List initialization without the equals sign (C++11 simplified syntax) std::array<int, 3> arr2{60, 70, 80}; // Size 3, elements 60, 70, 80 // Method 3: Partial initialization (unassigned elements are 0, unlike native arrays which have random values) std::array<int, 4> arr3{1, 2}; // First 2 elements are 1, 2, last 2 elements default to 0 std::array<int, 4> arr4 = arr3; // Directly assign arr3 to arr4
// Note: Size must be a constant; the following will fail to compile // int n = 5; // std::array<int, n> arr_err; // Error: n is a variable, not a compile-time constant return 0;}
Note that N must be a compile-time constant, not a variable, which is the same as arrays. The difference is that an array object can be assigned as a whole to another array object of the same size.
Traversing an Array: Three Common Methods for Different Scenarios
Array objects can also be accessed using subscript notation.
#include <array>#include <iostream>int main(){ std::array<std::string, 3> fruits{"apple", "banana", "cherry"}; // Method 1: Traditional for loop (suitable for scenarios needing index) std::cout << "Traditional for loop:"; for (size_t i = 0; i < fruits.size(); i++) // size() directly gets the size, no need to calculate manually { std::cout << "[" << i << "]" << fruits[i] << " "; // Output: [0]apple [1]banana [2]cherry } std::cout << std::endl;
// Method 2: Iterator traversal (STL common method, suitable for use with algorithms) std::cout << "Iterator traversal:"; for (auto it = fruits.begin(); it != fruits.end(); it++) // begin()=first element, end()=past-the-end position { std::cout << *it << " "; // *it gets the element pointed to by the iterator, output: apple banana cherry } std::cout << std::endl;
// Method 3: C++11 range for (most concise, suitable for just traversing elements) std::cout << "Range for traversal:"; for (const auto &fruit : fruits) // const& avoids copying, improving efficiency (especially when storing large objects) { std::cout << fruit << " "; // Output: apple banana cherry } return 0;}
To briefly explain, the begin() and end() methods in the example return the starting/ending iterator objects, pointing to the first and last elements, respectively. The current element can be retrieved using the * operator. Iterators are used in many containers and will be encountered frequently in the future.
Boundary Checking for Arrays:
One of the biggest advantages of arrays over native arrays is that they can perform boundary checks, throwing exceptions on out-of-bounds access:
#include <array>#include <iostream>#include <stdexcept> // Include exception classstd::out_of_rangeint main(){ std::array<int, 3> arr{1, 2, 3}; // [] access: no boundary check, out-of-bounds will lead to undefined behavior (program may crash, output garbage) arr[0] = 10; // Correct: modify the first element to 10 // arr[5] = 100; // Error: out-of-bounds access, no check, program may crash // at() access: has boundary check, out-of-bounds will throw std::out_of_range exception try { arr.at(1) = 20; // Correct: modify the second element to 20 arr.at(5) = 100; // Error: out-of-bounds, throws exception } catch (const std::out_of_range &e) // Catch exception { std::cout << "Out of bounds error:" << e.what() << std::endl; // Output: Out of bounds error: array::at: __n (which is 5) >= _Nm (which is 3) } // Verify modification results std::cout << "Final elements of arr:" << arr[0] << " " << arr[1] << " " << arr[2] << std::endl; // Output: 10 20 3 return 0;}
In summary: arrays and native arrays have the same storage method (both in stack or global area), the same access efficiency (both are stored contiguously, supporting random access), but are more convenient and safer to use. Therefore, in C++, it is recommended to use arrays instead of traditional arrays.
Vector: The “Dynamically Resizing” Array
If your data size is uncertain and requires “dynamic addition and removal” (for example, continuously adding data from user input), arrays and native arrays will not suffice, as they have fixed sizes. This is where vectors come into play: they are “dynamic arrays” that can automatically resize without manual memory management.
Vectors are the most commonly used container in STL, without exception, so it is essential to master their usage.
Vector Initialization: Supports Empty Initialization, List Initialization, and More
Vectors can dynamically adjust their size at runtime, and their initialization methods are more flexible than arrays, especially suitable for scenarios with “uncertain initial elements”:
#include <vector> // Must include the vector header file#include <iostream>#include <string>int main(){ // Method 1: Empty initialization (most common, later add elements using push_back) std::vector<int> vec1; // size=0 (no elements), capacity=0 (no allocated memory) // Method 2: Specify size and default value (suitable for scenarios with the same initial elements) std::vector<int> vec2(3, 10); // size=3, 3 elements are all 10, capacity=3 // Method 3: List initialization (C++11 and later, suitable for known initial elements) std::vector<std::string> vec3{"apple", "banana", "cherry"}; // size=3, capacity=3 // Method 4: Copy initialization (copy contents from another vector) std::vector<int> vec4(vec2); // vec4 is identical to vec2: size=3, elements 10, 10, 10 // Method 5: Iterator range initialization (copy part of elements from other containers) std::vector<int> vec5(vec3.begin(), vec3.end()); // Error: type mismatch (string cannot convert to int) std::vector<std::string> vec6(vec3.begin() + 1, vec3.end()); // Copy the 2nd-3rd elements of vec3: banana, cherry
return 0;}
Adding Elements to a Vector: push_back() and emplace_back()
The core functionality of vectors is “dynamically adding elements”, mainly using<span>push_back()</span> and <span>emplace_back()</span><span> (added in C++11, more efficient), both of which add new elements to the end.</span>
#include <vector>#include <iostream>#include <string>int main(){ std::vector<std::string> fruits; // 1. push_back(): first constructs a temporary object, then copies/moves it to the vector fruits.push_back("apple"); // Add string literal, will first convert to string temporary object fruits.push_back(std::string("banana")); // Directly pass string object, avoiding temporary conversion // 2. emplace_back(): added in C++11, directly constructs the object in the vector's memory (no temporary object, more efficient) fruits.emplace_back("cherry"); // Directly constructs string using "cherry", no temporary object // Comparison: if storing custom objects, the efficiency advantage of emplace_back() is more obvious // For example: fruits.emplace_back("date", 5); // If string has a two-parameter constructor, can directly pass parameters // Verify results std::cout << "Fruits elements (size=" << fruits.size() << "):"; for (const auto &f : fruits) { std::cout << f << " "; // Output: apple banana cherry } return 0;}
Removing Elements from a Vector: pop_back() and erase()
Removing elements from a vector is mainly done using<span>pop_back()</span> (removes the last element, simple and efficient) and <span>erase()</span> (removes elements at specified positions, note that iterators may become invalid):
#include <vector>#include <iostream>int main(){ std::vector<int> nums{10, 20, 30, 40, 50}; // 1. pop_back(): removes the last element, size decreases by 1, capacity remains unchanged (does not automatically shrink) nums.pop_back(); // Removes 50, nums becomes [10,20,30,40] std::cout << "After pop_back:"; for (const auto &n : nums) { std::cout << n << " "; // Output: 10 20 30 40 } std::cout << std::endl; // 2. erase(): removes elements at specified positions, returns new valid iterator (to avoid iterator invalidation) // Requirement: remove the element with value 30 (first find the iterator) auto it = nums.begin(); while (it != nums.end()) { if (*it == 30) { // Key: erase() will invalidate the current iterator, must update the iterator with the return value it = nums.erase(it); // Removes 30, returns the iterator to the next element (pointing to 40) } else { it++; // If not deleted, the iterator moves normally } } // Verify results std::cout << "After erase:"; for (const auto &n : nums) { std::cout << n << " "; // Output: 10 20 40 } return 0;}
Traversing a Vector: Similar to Array, but Supports Dynamic Size
The traversal methods for vectors are identical to those for arrays (traditional for, iterator, range for), with the only difference being that the<span>size()</span> of a vector changes with the addition and removal of elements:
#include <vector>#include <iostream>int main(){ std::vector<int> vec{1, 2, 3}; vec.push_back(4); // Dynamically add element, size changes from 3 to 4 // 1. Traditional for loop (using size() to get current dynamic size) std::cout << "Traditional for:"; for (size_t i = 0; i < vec.size(); i++) { std::cout << vec[i] << " "; // Output: 1 2 3 4 } std::cout << std::endl; // 2. Iterator traversal std::cout << "Iterator:"; for (auto it = vec.rbegin(); it != vec.rend(); it++) // rbegin()=last element, rend()=position before first (reverse traversal) { std::cout << *it << " "; // Output: 4 3 2 1 } std::cout << std::endl; // 3. Range for traversal std::cout << "Range for:"; for (auto n : vec) // No const&&, suitable for scenarios needing to modify elements { n *= 2; // Note: here modifies a copy, not the original vector's elements std::cout << n << " "; // Output: 2 4 6 8 } std::cout << "
Original vector elements:"; for (const auto &n : vec) { std::cout << n << " "; // Output: 1 2 3 4 (original elements unchanged) } return 0;}
Additionally, vectors also support subscript access and the at method for boundary checking, similar to arrays, so I will not repeat that here.
Memory Management Mechanism of Vectors
Vectors have two core concepts:<span>size</span> (current number of elements) and <span>capacity</span> (number of elements that can currently be stored in allocated memory), let’s look at the following example:
#include <vector>#include <iostream>int main(){ std::vector<int> vec; // Initial state: no elements, no allocated memory std::cout << "Initial: size=" << vec.size() << ", capacity=" << vec.capacity() << std::endl; // Output: size=0, capacity=0 // Add 1 element: capacity automatically expands (usually to 1) vec.push_back(10); std::cout << "push_back(10): size=" << vec.size() << ", capacity=" << vec.capacity() << std::endl; // Output: size=1, capacity=1 // Add another element: capacity is insufficient, automatically expands (usually to 2, double the original) vec.push_back(20); std::cout << "push_back(20): size=" << vec.size() << ", capacity=" << vec.capacity() << std::endl; // Output: size=2, capacity=2 // Add another element: capacity expands again (to 4) vec.push_back(30); std::cout << "push_back(30): size=" << vec.size() << ", capacity=" << vec.capacity() << std::endl; // Output: size=3, capacity=4 return 0;}
As you can see, capacity is the actual amount of memory allocated for the vector object, while size is the actual number of elements. Capacity is always greater than or equal to size. The automatic resizing of the vector changes the capacity but does not change the size.
You can adjust<span>reserve()</span> (pre-allocate memory) and <span>resize()</span><span> (adjust the number of elements) to change</span><span>capacity and size</span>
If you know approximately how many elements you will need, use<span>reserve(n)</span> to pre-allocate memory for n elements, avoiding automatic resizing (reducing copy overhead) and improving efficiency.
<span><span>resize(n)</span></span> will change the<span>size</span> to n: if n is larger than the original, new elements will use default values; if the capacity is insufficient, it will automatically expand; if n is smaller than the original, it will delete excess elements.
It is important to note that when a vector expands, it will copy old elements, and frequent expansions can reduce efficiency. If you know approximately how many elements you will need, it is best to use reserve() to pre-allocate memory, which can significantly improve performance.
Iterator Invalidation IssuesThis is the most common pitfall with vectors. When a vector expands, or when insert() or erase() methods are called, iterators may become invalid (the memory they point to is released or moved), and accessing the iterator will lead to undefined behavior (program crashes, output garbage, etc.).
// Error: iterator points to old memory after expansionstd::vector<int> vec{1, 2, 3};auto it = vec.begin();vec.push_back(4); // May trigger expansion, it becomes invalid*it = 10; // Undefined behavior!// Correct: re-obtain iterator after expansionvec.push_back(4);it = vec.begin(); // Re-point to new memory*it = 10; // Safe
Solutions: (1) Use reserve() to pre-allocate enough memory before expansion; (2) After erase(), update the old iterator with the new iterator returned (for example,<span>it = vec.erase(it)</span>).
Summary of Differences Between Vector, Array, and Native Arrays
The core difference between vectors and the latter two is “dynamism”, as shown in the table below:
| Feature | Native Array | std::array | std::vector |
|---|---|---|---|
| Size Modification | Fixed at compile time | Fixed at compile time | Dynamically adjusted at runtime |
| Memory Location | Stack / Global Area | Stack / Global Area | Heap (internally managed) |
| Expansion Mechanism | None (cannot expand) | None (cannot expand) | Automatically expands (allocates new memory → copies old elements → releases old memory) |
| Size and Capacity | None | size=capacity | size≤capacity |
| Memory Management | Automatic (stack) / Manual (heap) | Automatic (stack) | Automatic (internally managed heap memory) |
| Applicable Scenarios | Fixed size and simple scenarios | Fixed size with safety checks | Uncertain size, requires dynamic addition and removal |
STL is a vast template system that contains numerous contents. Today’s learning is just the first step towards STL. As learning deepens, one can gradually realize that STL is a key step from “C-style C++” to “modern C++”. Mastering STL can be said to be essential to truly being a C++ programmer. I will continue to share insights on learning STL in the future. Let’s work hard together!
Previous Articles
From Explicit to Smart: Core Features of C++ Class Templates
The Cornerstone of C++ Object-Oriented Programming—Classes
The Art of Type Conversion in C++—From Barbaric to Elegant Transformations