Level 5 — MasteryC++ Programming

C++ Programming

90 minutes60 marksprintable — key stays hidden on paper

Chapter: 5.2 C++ Programming Level: 5 — Mastery (cross-domain: build / prove / analyze) Time limit: 90 minutes Total marks: 60

Instructions: Answer all three questions. Code must compile under C++20. Where a proof or complexity argument is requested, state assumptions explicitly. Partial credit is awarded for correct reasoning even if code is incomplete.


Question 1 — RAII, Rule of Five, and Move Semantics on a Numeric Buffer (24 marks)

You are implementing a small linear-algebra primitive: a dynamically allocated Vec of double used to store an nn-dimensional vector, supporting the Euclidean norm x2=i=0n1xi2\lVert x\rVert_2 = \sqrt{\sum_{i=0}^{n-1} x_i^2}.

(a) Implement a class Vec owning a raw double* data_ and std::size_t n_ that satisfies the Rule of Five: destructor, copy constructor, copy assignment, move constructor, move assignment. Provide a parameterized constructor Vec(std::size_t n) (zero-initialized) and an operator[] (both const and non-const). Your move operations must be noexcept and must leave the moved-from object in a valid, destructible state. (10)

(b) Add a member double norm() const computing 2\lVert \cdot \rVert_2 using std::accumulate with a lambda. Explain why norm() is marked const and what would break in its signature if data_ were declared const double* vs double* const. (5)

(c) Consider:

Vec make() { Vec v(3); v[0]=3; v[1]=4; return v; }
Vec a = make();
Vec b = a;            // (i)
Vec c = std::move(a); // (ii)

State exactly which special member runs at lines (i) and (ii), whether copy elision applies to Vec a = make();, and the value of a.norm() after line (ii). Justify. (4)

(d) Prove that with your move constructor marked noexcept, a std::vector<Vec> reallocation during push_back will move rather than copy its elements, and explain the strong-exception-safety reason the standard library requires noexcept here. (5)


Question 2 — Templates, Concepts, and a Compile-Time Fold (20 marks)

(a) Write a variadic function template sum(args...) returning the sum of all arguments using a fold expression. Then constrain it with a C++20 concept Addable such that the template only participates in overload resolution when every argument type supports operator+ and is convertible to a common type. (8)

(b) Provide a template<typename T> constexpr T dot(const std::array<T,N>& a, const std::array<T,N>& b) (deduce N) computing the inner product iaibi\sum_i a_i b_i. Make it usable in a constexpr context and give a static_assert verifying that dot of a=(1,2,3)a=(1,2,3) and b=(4,5,6)b=(4,5,6) equals 3232. Show the arithmetic. (7)

(c) Explain SFINAE vs concepts for the constraint in (a): give one concrete diagnostic-quality difference in behavior when an unsupported type (e.g. a type with no operator+) is passed. (5)


Question 3 — Concurrency, the Memory Model, and a Producer/Consumer Proof (16 marks)

A producer thread computes partial sums of a physics simulation and hands a result to a consumer.

(a) Using std::atomic<int> with an explicit data field, implement a release–acquire handoff: producer writes data, then does flag.store(1, std::memory_order_release); consumer spins on flag.load(std::memory_order_acquire)==1 then reads data. Prove, using the happens-before relation, that the consumer is guaranteed to observe the producer's write to data (no data race, no torn/stale read). (8)

(b) Reimplement the same handoff with std::condition_variable, std::mutex, and std::unique_lock, avoiding busy-waiting and the lost-wakeup problem. Explain precisely why the wait must use a predicate (or a while loop) rather than a bare wait(). (5)

(c) State the three levels of exception-safety guarantee and classify: a function that (i) only reads, marked noexcept; (ii) modifies a container via copy-and-swap; (iii) appends to a std::vector that may throw bad_alloc mid-operation. (3)

Answer keyMark scheme & solutions

Question 1

(a) Rule of Five (10 marks)

#include <algorithm>
#include <cstddef>
#include <numeric>
#include <cmath>
#include <utility>
 
class Vec {
    double* data_ = nullptr;
    std::size_t n_ = 0;
public:
    explicit Vec(std::size_t n) : data_(new double[n]{}), n_(n) {}   // zero-init (1)
 
    ~Vec() { delete[] data_; }                                       // (1)
 
    Vec(const Vec& o) : data_(new double[o.n_]), n_(o.n_) {          // copy ctor (2)
        std::copy(o.data_, o.data_ + n_, data_);
    }
    Vec& operator=(const Vec& o) {                                   // copy assign (2)
        if (this != &o) { Vec tmp(o); swap(tmp); }                  // copy-and-swap
        return *this;
    }
    Vec(Vec&& o) noexcept : data_(o.data_), n_(o.n_) {              // move ctor (2)
        o.data_ = nullptr; o.n_ = 0;                                // valid moved-from
    }
    Vec& operator=(Vec&& o) noexcept {                              // move assign (1)
        if (this != &o) { delete[] data_; data_ = o.data_; n_ = o.n_;
                          o.data_ = nullptr; o.n_ = 0; }
        return *this;
    }
    void swap(Vec& o) noexcept { std::swap(data_, o.data_); std::swap(n_, o.n_); }
 
    double& operator[](std::size_t i)       { return data_[i]; }     // (1)
    const double& operator[](std::size_t i) const { return data_[i]; }
    std::size_t size() const { return n_; }
    double norm() const;
};

Marks: param ctor+dtor (2); copy pair (4); move pair noexcept + null-out (4). Why: moved-from must be delete[]-safe → set data_=nullptr (deleting nullptr is legal). noexcept enables the vector-growth optimization (part d).

(b) norm() (5 marks)

double Vec::norm() const {
    return std::sqrt(std::accumulate(data_, data_ + n_, 0.0,
        [](double acc, double x){ return acc + x*x; }));
}
  • const because computing the norm doesn't modify the vector; allows calling on const Vec (2).
  • const double* data_ → pointer to const data: operator[] non-const returning double& would fail to compile (can't return mutable ref) — makes the data immutable (2).
  • double* const data_ → const pointer, mutable data: you can change elements but not reseat the pointer; copy/move assignment reassigning data_ would fail to compile (1).

(c) Special members & elision (4 marks)

  • Vec a = make(); : copy elision / RVO — no move or copy runs (in C++17+ guaranteed for the prvalue return via NRVO is not guaranteed but the temporary→a is elided). The named local v return may use NRVO or the move ctor. (1)
  • Line (i) Vec b = a;copy constructor (a is an lvalue). (1)
  • Line (ii) Vec c = std::move(a);move constructor. (1)
  • After (ii), a is moved-from: data_=nullptr, n_=0. a.norm() = accumulate over empty range = sqrt(0.0) = 0.0 (and is safe). (1)

(d) Proof move is used in vector growth (5 marks)

std::vector::push_back growth relocates existing elements. The standard (via std::move_if_noexcept) chooses the move constructor iff it is noexcept (or no copy ctor exists). (2) Reasoning: during reallocation the vector must provide the strong guarantee — if relocating element kk throws, elements already moved into the new buffer would leave the container in a corrupted, unrecoverable state (moved-from originals are damaged). A throwing move cannot be rolled back, so the library falls back to copy (which leaves originals intact and can be rolled back). Marking move noexcept promises no throw → library safely moves. (3)


Question 2

(a) Variadic sum + Addable concept (8 marks)

#include <concepts>
#include <type_traits>
 
template<typename... Ts>
concept Addable = requires(Ts... a) {
    (... + a);                                   // fold: all support operator+
} && requires { typename std::common_type_t<Ts...>; };
 
template<typename... Ts>
    requires Addable<Ts...>
constexpr auto sum(Ts... args) {
    return (args + ...);                         // unary right fold
}

Marks: fold expression (3); concept with requires on operator+ (3); common_type / convertibility (2).

(b) constexpr dot (7 marks)

#include <array>
#include <cstddef>
 
template<typename T, std::size_t N>
constexpr T dot(const std::array<T,N>& a, const std::array<T,N>& b) {
    T s{};
    for (std::size_t i = 0; i < N; ++i) s += a[i]*b[i];
    return s;
}
 
static_assert(dot(std::array{1,2,3}, std::array{4,5,6}) == 32);

Arithmetic: 14+25+36=4+10+18=321\cdot4 + 2\cdot5 + 3\cdot6 = 4 + 10 + 18 = 32. (3) Marks: deduce N (2); constexpr loop (2); static_assert + shown arithmetic (3).

(c) SFINAE vs Concepts (5 marks)

  • SFINAE: substitution failure in the immediate context silently removes the overload; if no viable overload remains you get a cryptic "no matching function" error, often buried in template instantiation depth. (2)
  • Concepts: the compiler reports which named constraint was not satisfied (e.g. "constraint Addable<...> not satisfied: (... + a) is invalid"), a far cleaner diagnostic; concepts also subsume/order overloads and are checked before instantiation. (3)

Question 3

(a) Release–Acquire handoff + happens-before proof (8 marks)

#include <atomic>
int data = 0;
std::atomic<int> flag{0};
 
void producer() {
    data = 42;                                    // (W1) plain write
    flag.store(1, std::memory_order_release);     // (R) release store
}
void consumer() {
    while (flag.load(std::memory_order_acquire) != 1) {}  // (A) acquire load
    int x = data;                                 // (R2) plain read, sees 42
}

Proof (happens-before): (5)

  1. In the producer thread, (W1) is sequenced-before the release store (R).
  2. The acquire load (A) in consumer reads the value written by release store (R) → this forms a release–acquire synchronizes-with relation: (R) synchronizes-with (A).
  3. (A) is sequenced-before (R2).
  4. By transitivity of the happens-before relation (sequenced-before ∘ synchronizes-with ∘ sequenced-before), (W1) happens-before (R2).
  5. Because a happens-before relation exists between the write and read of data, there is no data race, and the memory model guarantees (R2) observes the value 42. ∎

Marks: correct memory orders (3); the 4-step transitive happens-before chain (5).

(b) condition_variable version (5 marks)

#include <mutex>
#include <condition_variable>
std::mutex m; std::condition_variable cv; bool ready=false; int data2=0;
 
void producer() {
    { std::lock_guard<std::mutex> lk(m); data2 = 42; ready = true; }
    cv.notify_one();
}
void consumer() {
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk, []{ return ready; });   // predicate!
    int x = data2;                      // safe
}

Why predicate/while (3): wait() can return due to spurious wakeups, and a notification sent before the consumer entered wait would be lost. The predicate re-checks ready under the lock: if the condition already holds, wait returns immediately (no lost wakeup); on a spurious wakeup with the predicate false, it re-waits. A bare wait() handles neither. Marks: unique_lock + notify (2); predicate reasoning (3).

(c) Exception-safety guarantees (3 marks)

  • No-throw (nofail): never throws — (i). (1)
  • Strong: commit-or-rollback, state unchanged on throw — (ii) copy-and-swap. (1)
  • Basic: no leaks, invariants preserved but state may change — (iii) vector append that may throw bad_alloc (vector itself gives strong for push_back, but a mid-operation user append sequence gives only basic). (1)
[
  {"claim":"dot of (1,2,3) and (4,5,6) equals 32","code":"a=[1,2,3]; b=[4,5,6]; result = (sum(x*y for x,y in zip(a,b)) == 32)"},
  {"claim":"norm of (3,4,0) equals 5","code":"import math; v=[3,4,0]; result = (math.sqrt(sum(x*x for x in v)) == 5.0)"},
  {"claim":"norm of empty moved-from vector is 0","code":"import math; v=[]; result = (math.sqrt(sum(x*x for x in v)) == 0.0)"},
  {"claim":"sum fold of 1,2,3,4 equals 10","code":"result = (sum([1,2,3,4]) == 10)"}
]