- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Safe Ownership and Runtime Polymorphism in C++
Meridian Field Station runs a scattered fleet of environmental sensors (thermometers, hygrometers) that report back to a base controller over intermittent links. The existing station code was written years ago with raw arrays of Sensor*, manual new/delete, static_cast downcasts to reach thermometer-only calibration, and exceptions thrown for routine sensor timeouts -- and it crashes in the field with use-after-free and out-of-bounds reads. You are modernizing this codebase step by step: first making failures explicit and recoverable, then fixing the raw-pointer bugs, then moving ownership into RAII and smart pointers, and finally wiring a safe polymorphic fleet controller.
Lab Info
Table of Contents
-
Challenge
Step 1: Orientation to the Meridian Field Station codebase
Meridian Field Station
The station's controller code has been running unmodified for years. It works, mostly — until a sensor drops off the link, or two requests race on the same slot, and the station either crashes or silently reports nonsense. Over the next four steps you will rebuild the pieces that fail in the field: how a reading reports failure, how the sensor registry owns its sensors, and how the fleet controller talks to every sensor type without corrupting memory.
Nothing here is a rewrite from scratch. You are editing the station's existing files in place, the way you would on a real legacy codebase — fixing the bug in front of you before reaching for the next abstraction. ## What's actually wrong
Four smells run through this codebase, and each maps directly onto a step ahead:
- Exceptions for routine failures. A sensor timeout — which happens on a bad link several times an hour — is currently thrown as a C++ exception and caught (or not) several call frames away. Step 2 replaces that with an explicit result the caller checks, reserving exceptions for the one failure the station truly cannot start with: a nonsensical calibration config.
- Raw
Sensor*array with hand-rolled bookkeeping. The registry'sfindSensorByName,removeSensor, andgetSensorAteach trust an index or a count that isn't always right, which is exactly how you get a read past the end of the array or a dereference of a pointer that was alreadydeleted. Step 3 fixes each of these in place. - Manual
new/deleteand a raw connection handle. AConnectionHandleis opened and closed by hand at each call site, and the sensor registry allocates withnewand hopes every path remembers todelete. Step 4 moves both into RAII and Rule-of-Zero containers so the compiler enforces cleanup instead of a comment reminding a future maintainer. static_castused as a shortcut past the type system. The calibration routine assumes every sensor in the fleet is aThermometerandstatic_casts to reachcalibrate(), which is undefined behavior the moment aHygrometeris in the mix. Step 5 wires the fleet controller through the sensors' shared virtual interface and swaps thatstatic_castfor a checkeddynamic_cast. ## Where this lands
By the end of step 5 you will have a small but complete
FleetControllerthat polls every registered sensor through its virtual interface, calibrates the thermometers among them without disturbing the rest of the fleet, and folds both outcomes into one station report — all without a single rawnew, an unchecked cast, or an exception thrown for something as ordinary as a dropped link.The fixes are cumulative: the registry you repair in step 3 is the same registry you convert to owning smart pointers in step 4, and the sensors you give a safe virtual interface in step 5 are the ones step 2 taught to report their own failures. Read each file as you reach it — the bug or the gap is usually a handful of lines away from where the TODO sits.
info> If you get stuck, you can refer to the provided solution code for each task, available in the
solutionfolder. -
Challenge
Step 2: Build the foundational sensor-reading result reporting
Two kinds of failure
A sensor timeout is routine — it happens whenever the radio link is briefly down — and the caller needs to know about it without paying for a C++ exception on the common path. A bad calibration config, by contrast, means the station should not start at all: there is no reasonable way to "recover" from a negative scale factor mid-run.
src/sensor_reading.cppseparates the two.ReadingResultis a small value type withok(), a.value, and an error code, built with theReadingResult::success(...)/ReadingResult::failure(...)factories already declared for you. A function that can fail routinely returns one of these instead of throwing. A function that guards a precondition the whole station depends on — like the calibration config it starts with — still throws, because that failure is not something a caller should be expected to check for on every call. ## Keeping the exception path rareWith readSensorValue now reporting timeouts explicitly, the only throw left in this file should be for a failure the station cannot run with at all. That is exactly what
validateCalibrationConfigguards: it runs once, at startup, before a single sensor is polled, so throwing there costs nothing and stops a misconfigured station before it does any damage. -
Challenge
Step 3: Expose and fix raw pointer lifetime failures in the legacy sensor array
The legacy registry
src/sensor_registry.cppstill owns its sensors the way it did years ago: a fixed-size array ofSensor*and anint count_tracking how many slots are actually in use. Three of its methods trust that bookkeeping in a way that occasionally isn't true — a loop bound that doesn't matchcount_, a delete that leaves its pointer where the next lookup will find it, and an index that's never checked against anything before it's dereferenced.You will fix each one where it stands, without changing the array yet — that conversion comes in step 4. For now, the goal is simply that every method respects the same invariant: only the first
count_slots ofsensors_hold a sensor that's still alive. ## Deleting without danglingfindSensorByNamenow trustscount_to mark the boundary of live sensors.removeSensoris the method responsible for keeping that boundary honest: when it deletes a sensor, it has to make sure nothing later in the array — or incount_itself — still points at, or counts, the slot it just freed. ## The last unchecked accessEven with the array kept contiguous,
getSensorAtstill takes an arbitrary caller-supplied index and dereferencessensors_[index]without asking whether that index is one it has ever assigned. This is the same class of bug as the out-of-bounds read infindSensorByName, just reached through a public accessor instead of a search loop — and it gets the same fix: check the bound before you trust it. -
Challenge
Step 4: Move sensor ownership into RAII and Rule-of-Zero containers
RAII: tying a resource to an object's lifetime
A raw connection handle that's opened in one function and closed in another only stays balanced as long as every code path between them remembers to close it — including the ones that return early or throw. RAII removes that obligation by tying the resource to an object: the constructor acquires it, the destructor releases it, and the object's own lifetime — which the compiler tracks for you — decides when that happens.
src/connection_handle.cppdeclaresConnectionHandlewith its copy operations already deleted, because a handle should have exactly one owner at a time. What's missing is the constructor and destructor that actually acquire and release it, and the move operations that transfer ownership from oneConnectionHandleto another without ever closing the same handle twice. ## Rule of ZeroA class that manages a resource through a member that already manages itself — a
std::vector, astd::unique_ptr— doesn't need a custom destructor, copy constructor, or move constructor at all: the compiler-generated ones already do the right thing, because they just call the same operations on each member. That's the Rule of Zero, and it's the opposite of the fixedSensor* sensors_[kMaxSensors]array you've been patching in step 3.addSensoris the one method left that still needs to change: it should take ownership of a new sensor through aunique_ptrparameter and release it into the array, instead of the caller managing a raw new/delete itself. ## Shared ownershipstd::unique_ptrsays "exactly one owner." Sometimes that's wrong on purpose — several sensors legitimately need to read the same calibration data, and none of them should have to duplicate it just to hold a reference.std::shared_ptris for that case: copying one doesn't copy the pointee, it increments a shared reference count, and the pointee is only destroyed once everyshared_ptrto it is gone.src/calibration_profile.cppdeclaresCalibrationProfileand aSensor::profile_member of typestd::shared_ptr<CalibrationProfile>.attachSharedProfileis the function that hands the same profile to every sensor in a fleet. -
Challenge
Step 5: Wire the fleet controller through virtual interfaces safely
Virtual dispatch without slicing
Sensordeclaresread()as virtual, andThermometerandHygrometereach override it with their own conversion logic. Calling that override safely depends on going through a pointer or reference to the base class —sensor->read()on aSensor*orunique_ptr<Sensor>— rather than through aSensorheld by value. Assigning aThermometerinto aSensorvariable copies only the base-class part of it, discarding the derived data and the override along with it; that's slicing, and it's why the fleet is stored asstd::vector<std::unique_ptr<Sensor>>rather than a vector ofSensorvalues.src/fleet_controller.cppis where the fleet controller lives.pollFleetis its first job: read every sensor in the fleet through that shared interface. ## dynamic_cast instead of static_caststatic_castto a derived type trusts the caller completely: if the object underneath isn't actually that derived type, the cast still "succeeds" and produces a pointer to memory that was never laid out the way the derived class expects.dynamic_castchecks the object's real type at runtime and returnsnullptrwhen the cast doesn't hold — which is exactly the check you need before calling a method, likeThermometer::calibrate, that only thermometers have.calibrateThermometerswalks the same fleetpollFleetjust read, but only some of those sensors are thermometers. ## Bringing it togetherpollFleetandcalibrateThermometerseach do one job well.generateStationReportis where those two results become the single piece of text the rest of the station actually consumes once per cycle — every sensor's reading or failure reason, and a note on how many thermometers were just calibrated. -
Challenge
Step 6: Run the application
Every task is done, and each one was graded the moment you completed it. What you have not done yet is watch the whole application run at once -- which is the only place the pieces you wrote separately become a single working program.
Start the application
Open the Terminal tab and run:
make app && ./appThe program runs in the terminal and prints its output there. Every behaviour it shows is code you wrote in the previous steps, so read the output against what each task asked for -- that is the whole lab, working as one program. ### Run the checks yourself
The lab ran each task's test for you as you went, but they are ordinary tests and you can run them whenever you like. The whole suite:
bash runTest.shOr one task at a time:
bash runTest.sh "task 2.1"Each failure names the task it belongs to, so the output tells you which step to go back to rather than which line to stare at. Two things worth trying before you finish: break something on purpose and watch the matching check fail, then put it back. Knowing what a failure looks like is worth as much as knowing what a pass looks like, and it costs you nothing here.
When you are done, everything you wrote is still in the editor tabs -- the lab is yours to keep reading.
About the author
Real skill practice before real-world application
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.