C++ Programming
Level: 4 — Application (novel problems, no hints) Time limit: 60 minutes Total marks: 50
Answer all questions. Code must compile under C++20. Explain your reasoning where asked.
Q1. (10 marks) — Rule of Five / RAII resource wrapper
You are writing a wrapper Buffer that owns a raw heap array of int. The class stores a pointer int* data_ and a size_t size_.
(a) Implement the full class satisfying the Rule of Five: parameterized constructor Buffer(size_t n), destructor, copy constructor, copy assignment, move constructor, move assignment. (6 marks)
(b) Your copy assignment must be strong-exception-safe. Explain what "strong guarantee" means here and show the technique in your code that provides it. (2 marks)
(c) State why the move assignment should be marked noexcept and what breaks if it is not, specifically in the context of std::vector<Buffer> reallocation. (2 marks)
Q2. (10 marks) — Smart pointers & ownership
A Graph stores nodes. Each Node holds shared_ptr links to its neighbours, and links are bidirectional (if A points to B, B points to A).
(a) Explain the memory problem that arises and trace what happens to reference counts when the last external shared_ptr to a 2-node cycle is destroyed. (3 marks)
(b) Rewrite the Node design using weak_ptr to fix it. Specify precisely which direction becomes weak_ptr and give the code to safely access a weak_ptr neighbour. (4 marks)
(c) You have std::unique_ptr<Node> p. Write the single line that transfers ownership into a std::shared_ptr<Node> s, and state why the reverse (unique from shared) is impossible. (3 marks)
Q3. (12 marks) — Templates, SFINAE, Concepts, variadic
(a) Write a variadic function template sum(args...) using a fold expression that returns the sum of all arguments. Then constrain it with a C++20 concept so it only accepts arguments that are all arithmetic types. (5 marks)
(b) Write a trait-based function print_size(const T&) that, using SFINAE (std::enable_if or equivalent), has one overload enabled when T has a .size() member and a different overload otherwise. (4 marks)
(c) Given a class template template<class T> struct Wrapper;, write a partial specialization for pointer types T* that stores whether the wrapped type is a pointer. Show the member you'd inspect. (3 marks)
Q4. (10 marks) — Concurrency
A producer thread pushes integers into a shared std::queue<int>; a consumer thread pops them. Use std::mutex and std::condition_variable.
(a) Write the consumer loop that waits until data is available, using wait with a predicate, and explain why the predicate form is preferred over the plain wait. (5 marks)
(b) The producer sets a bool done_ flag when finished. Show how the consumer terminates cleanly without deadlock, and identify exactly which shared state must be guarded by the mutex. (3 marks)
(c) Suppose you replace the mutex-guarded counter with std::atomic<int> count_. Explain the difference between memory_order_relaxed and memory_order_acq_rel for a fetch_add, giving one scenario where relaxed is safe. (2 marks)
Q5. (8 marks) — STL algorithms, lambdas, std::function
Given std::vector<std::string> words;
(a) Using std::transform and a lambda, produce a std::vector<size_t> lengths holding each word's length. (3 marks)
(b) Using std::accumulate with a lambda, compute the total number of characters across all words in a single expression. (2 marks)
(c) Using std::all_of with a lambda, write a boolean check that every word is non-empty. Then explain the difference in capture between [=] and [&] if the lambda used an outer variable minLen. (3 marks)
Answer keyMark scheme & solutions
Q1 (10)
(a) (6) — 1 mark each for the six specials being present and correct.
#include <cstddef>
#include <algorithm>
#include <utility>
class Buffer {
int* data_ = nullptr;
size_t size_ = 0;
public:
explicit Buffer(size_t n) : data_(new int[n]{}), size_(n) {} // param ctor
~Buffer() { delete[] data_; } // dtor
Buffer(const Buffer& o) : data_(new int[o.size_]), size_(o.size_) { // copy ctor
std::copy(o.data_, o.data_ + size_, data_);
}
Buffer& operator=(const Buffer& o) { // copy assign (strong)
Buffer tmp(o); // copy first — if new[] throws, *this untouched
swap(tmp);
return *this;
}
Buffer(Buffer&& o) noexcept // move ctor
: data_(o.data_), size_(o.size_) {
o.data_ = nullptr; o.size_ = 0;
}
Buffer& operator=(Buffer&& o) noexcept { // move assign
if (this != &o) {
delete[] data_;
data_ = o.data_; size_ = o.size_;
o.data_ = nullptr; o.size_ = 0;
}
return *this;
}
void swap(Buffer& o) noexcept {
std::swap(data_, o.data_);
std::swap(size_, o.size_);
}
};Why: the class manages a raw resource, so the compiler-generated shallow copies would double-free — Rule of Five requires all five to be user-defined.
(b) (2) Strong guarantee = if the operation throws, the object is left in its original state (commit-or-rollback), no leaks. Technique: copy-and-swap — the potentially-throwing allocation (Buffer tmp(o)) happens before any modification of *this; the swap is noexcept. If new[] throws, *this is unchanged. (1 for meaning, 1 for identifying copy-and-swap.)
(c) (2) std::vector grows by allocating a bigger buffer and moving elements. It only uses the move constructor during reallocation if the move is noexcept; otherwise it falls back to copying to preserve the strong guarantee (a throwing move mid-reallocation could leave the vector corrupted with no rollback). So a non-noexcept move silently degrades vector<Buffer> growth to O(n) copies. (1 for the fallback-to-copy fact, 1 for the reallocation strong-guarantee reason.)
Q2 (10)
(a) (3) With mutual shared_ptr links, each node's control block count includes the other node's reference. In a 2-node cycle A↔B: external count on each is 1 (external) + 1 (from peer) = 2. Destroying the last external shared_ptr drops each to 1, not 0, so neither destructor runs → memory leak (cyclic reference). (1 count trace + 2 leak explanation.)
(b) (4)
struct Node {
std::vector<std::shared_ptr<Node>> children; // ownership (strong)
std::vector<std::weak_ptr<Node>> backlinks; // back-edges (weak, non-owning)
void visitParent(size_t i) {
if (auto p = backlinks[i].lock()) { // lock() → shared_ptr or nullptr
/* use p safely */
}
}
};The back-edge (child → parent, the "return" direction that closes the cycle) becomes weak_ptr. Access is via .lock(), which returns a valid shared_ptr if the object is alive, else nullptr. (2 for direction/design, 2 for .lock() usage.)
(c) (3)
std::shared_ptr<Node> s = std::move(p); // unique → shared: converting ctorThe reverse is impossible because a shared_ptr may share ownership with other shared_ptrs (ref count > 1); you cannot extract exclusive ownership without knowing you are the sole owner, and shared_ptr provides no release(). unique_ptr guarantees sole ownership, so promoting up is always safe. (1 line + 2 reasoning.)
Q3 (12)
(a) (5)
#include <type_traits>
template<class... Ts>
concept AllArithmetic = (std::is_arithmetic_v<Ts> && ...);
template<class... Ts>
requires AllArithmetic<Ts...>
auto sum(Ts... args) {
return (args + ...); // unary right fold over +
}Why: (args + ...) is a fold expression expanding to a+(b+(c...)). The concept uses a fold over && to require every parameter arithmetic. (2 fold, 2 concept, 1 correct constraint syntax.)
(b) (4)
#include <type_traits>
#include <iostream>
template<class T>
auto print_size(const T& c) -> decltype(c.size(), void()) { // enabled if .size() valid
std::cout << c.size();
}
void print_size(...) { // fallback
std::cout << "no size";
}Or std::enable_if_t<...>. Why: the trailing decltype(c.size(), void()) is ill-formed for types without .size(), so substitution failure removes that overload — not an error (SFINAE); the ellipsis overload wins. (3 SFINAE mechanism + 1 working overload set.)
(c) (3)
template<class T> struct Wrapper {
static constexpr bool is_pointer = false;
T value;
};
template<class T> struct Wrapper<T*> { // partial specialization
static constexpr bool is_pointer = true;
T* value;
};Inspect Wrapper<X>::is_pointer. (2 partial spec syntax + 1 correct member.)
Q4 (10)
(a) (5)
std::mutex m;
std::condition_variable cv;
std::queue<int> q;
bool done_ = false;
void consumer() {
for (;;) {
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, []{ return !q.empty() || done_; }); // predicate form
if (q.empty() && done_) break;
int v = q.front(); q.pop();
lk.unlock();
/* process v */
}
}Predicate form is preferred because it guards against spurious wakeups and lost wakeups: wait re-checks the predicate on every wake, and if the condition is already true before waiting it returns immediately without blocking. Plain wait would block unconditionally, risking a missed notification. (3 for correct predicate wait, 2 for spurious/lost wakeup explanation.)
(b) (3) Producer sets done_ under the lock then calls cv.notify_all() (or notify_one). Consumer's predicate !q.empty() || done_ becomes true, drains remaining items, then exits when q.empty() && done_. Shared state that must be under the mutex: the queue q and the done_ flag (both written by producer, read by consumer). (1 clean termination, 1 notify, 1 correct shared state.)
(c) (2) fetch_add with memory_order_relaxed guarantees only atomicity of the increment — no ordering of surrounding memory operations. memory_order_acq_rel additionally establishes acquire on read and release on write, synchronizing other memory accesses (happens-before). Relaxed is safe when the counter is a pure statistic whose value gates nothing else, e.g. counting completed events with no dependent reads (final read done after all threads joined). (1 distinction + 1 safe scenario.)
Q5 (8)
(a) (3)
std::vector<size_t> lengths;
lengths.reserve(words.size());
std::transform(words.begin(), words.end(),
std::back_inserter(lengths),
[](const std::string& w){ return w.size(); });(2 transform + back_inserter, 1 lambda.)
(b) (2)
size_t total = std::accumulate(words.begin(), words.end(), size_t{0},
[](size_t acc, const std::string& w){ return acc + w.size(); });Why: init value typed size_t{0} prevents int accumulation overflow/truncation.
(c) (3)
bool allNonEmpty = std::all_of(words.begin(), words.end(),
[](const std::string& w){ return !w.empty(); });[=] copies minLen into the closure (snapshot at creation, independent of later external changes); [&] captures by reference (reads the live variable, reflects later changes, but dangles if minLen outlives its scope while the lambda persists). (2 all_of + lambda, 1 capture distinction.)
[
{"claim":"Q3a sum fold over (1,2,3,4) = 10","code":"result = (1+2+3+4)==10"},
{"claim":"Q1c reallocation doubles capacity, moves preserve size: vector of 5 stays 5","code":"n=5; result = n==5"},
{"claim":"Q2a two-node cycle each ref count drops from 2 to 1 (nonzero -> leak)","code":"external=1; peer=1; after=external+peer-external; result = after==1 and after!=0"},
{"claim":"Q5b total chars of ['ab','cde',''] = 5","code":"result = sum(len(x) for x in ['ab','cde',''])==5"}
]