Learning Goals 5 min
A struct with functions inside it is a class. Arduino libraries (Servo, Stepper, WiFi, SSD1306) are all classes. Today you write your own. By the end of this lesson you will:
- Declare a class with private data and public methods.
- Write a constructor that initialises the instance — same role as
Servo s(...)when you first use Servo. - Create multiple independent instances of your class, each carrying its own state.
Warm-Up 10 min
You've been creating class instances since L01:
Servo myServo;— instance of classServo.Stepper motor(2048, 8, 10, 9, 11);— constructor call.Adafruit_SSD1306 display(128, 64, &Wire, -1);— another constructor.myServo.write(90)— calling a method on the instance.
Today we open the box. A class is a struct that owns its own behaviour.
Tiny preview
class Counter {
public:
Counter() : count(0) {}
void inc() { count++; }
int get() { return count; }
private:
int count;
};
Counter a;
Counter b;
void setup() {
a.inc(); a.inc(); a.inc(); // a == 3
b.inc(); // b == 1
}Two independent counters. Each has its own count. The class definition is written once; the data lives per-instance.
New Concept · Anatomy of a class 25 min
The four parts
class LED {
public:
// Constructor — runs when you create an instance
LED(int pin) : pin_(pin), level_(0) {
pinMode(pin_, OUTPUT);
}
// Public methods — the interface
void on() { analogWrite(pin_, 255); level_ = 255; }
void off() { analogWrite(pin_, 0); level_ = 0; }
void setLevel(int v) {
v = constrain(v, 0, 255);
analogWrite(pin_, v);
level_ = v;
}
int getLevel() { return level_; }
private:
// Private data — owned by this instance, hidden from outside
int pin_;
int level_;
};Four parts: class name, public methods (the interface), private data (the state), and a constructor (sets things up).
Public vs private
public:— anyone can call. The class's interface to the outside world.private:— only methods of this class can access. The internal state.
Hiding data behind methods is called encapsulation. Code outside the class can't accidentally set level_ = 9999 — the only way to change it is via setLevel(), which constrains the value.
Constructor — the init function
LED(int pin) : pin_(pin), level_(0) {
pinMode(pin_, OUTPUT);
}The : pin_(pin), level_(0) after the parameter list is the member initializer list — sets each member before the body runs. Then the body (pinMode) executes.
Convention: trailing underscore (pin_) for private member names so they don't collide with constructor parameters.
Creating instances
LED red(9);
LED green(10);
LED blue(11);
void setup() {
red.on();
green.setLevel(128);
blue.off();
}Three independent LEDs. Each has its own pin_ and level_. Methods called on one don't affect the others.
Where to put the class
For a small class, top of the .ino file is fine. For reusable ones, put the declaration in a .h header and the implementation in a .cpp file. The Arduino IDE will compile both. Library structure (L04-41 covers this in detail):
// LED.h
#ifndef LED_H
#define LED_H
#include <Arduino.h>
class LED {
public:
LED(int pin);
void on();
void off();
void setLevel(int v);
int getLevel();
private:
int pin_;
int level_;
};
#endif// LED.cpp
#include "LED.h"
LED::LED(int pin) : pin_(pin), level_(0) {
pinMode(pin_, OUTPUT);
}
void LED::on() { analogWrite(pin_, 255); level_ = 255; }
void LED::off() { analogWrite(pin_, 0); level_ = 0; }
void LED::setLevel(int v) {
v = constrain(v, 0, 255);
analogWrite(pin_, v);
level_ = v;
}
int LED::getLevel() { return level_; }The LED:: prefix tells the compiler "this is the LED class's version of this method". Splits declaration (in .h, what to call) from implementation (in .cpp, how it works).
Static vs instance members
class LED {
public:
static int instanceCount;
LED(int pin) : pin_(pin) { instanceCount++; }
// ...
private:
int pin_;
};
int LED::instanceCount = 0; // define the static
LED a(9), b(10), c(11);
// LED::instanceCount == 3static members belong to the class as a whole, not to any instance. Useful for shared counters, singleton resources, factory methods.
Worked Example · A Sensor class 25 min
Take yesterday's Sensor struct and turn it into a class that owns its own smoothing window.
Step 1 — the class
// Top of the sketch — a small inline class
class SmoothedSensor {
public:
SmoothedSensor(const char* name, int pin, int threshold)
: name_(name), pin_(pin), threshold_(threshold),
histIndex_(0), histSum_(0), smoothed_(0), alarmActive_(false) {
for (int i = 0; i < WINDOW; i++) history_[i] = 0;
}
void begin() {
pinMode(pin_, INPUT);
int first = analogRead(pin_);
for (int i = 0; i < WINDOW; i++) history_[i] = first;
histSum_ = (long)first * WINDOW;
smoothed_ = first;
}
void update() {
int r = analogRead(pin_);
histSum_ -= history_[histIndex_];
history_[histIndex_] = r;
histSum_ += r;
histIndex_ = (histIndex_ + 1) % WINDOW;
smoothed_ = histSum_ / WINDOW;
alarmActive_ = smoothed_ > threshold_;
}
const char* name() const { return name_; }
int value() const { return smoothed_; }
bool alarm() const { return alarmActive_; }
private:
static const int WINDOW = 10;
const char* name_;
int pin_;
int threshold_;
int history_[WINDOW];
int histIndex_;
long histSum_;
int smoothed_;
bool alarmActive_;
};
SmoothedSensor light("Light", A0, 800);
SmoothedSensor temp ("Temp", A1, 600);
SmoothedSensor loud ("Loud", A2, 900);
void setup() {
Serial.begin(9600);
light.begin();
temp.begin();
loud.begin();
}
void loop() {
light.update();
temp.update();
loud.update();
Serial.print(light.name()); Serial.print("="); Serial.print(light.value()); if (light.alarm()) Serial.print("!");
Serial.print(" / ");
Serial.print(temp.name()); Serial.print("="); Serial.print(temp.value()); if (temp.alarm()) Serial.print("!");
Serial.print(" / ");
Serial.print(loud.name()); Serial.print("="); Serial.print(loud.value()); if (loud.alarm()) Serial.print("!");
Serial.println();
delay(200);
}Step 2 — compile and run
Three sensors, each with its own smoothing window stored inside its own instance. Output:
Light=412 / Temp=583 / Loud=905! Light=415 / Temp=584 / Loud=901! Light=412 / Temp=585 / Loud=898
Step 3 — array of class instances
SmoothedSensor sensors[] = {
SmoothedSensor("Light", A0, 800),
SmoothedSensor("Temp", A1, 600),
SmoothedSensor("Loud", A2, 900),
};
const int N = sizeof(sensors) / sizeof(sensors[0]);
void setup() {
for (int i = 0; i < N; i++) sensors[i].begin();
}
void loop() {
for (int i = 0; i < N; i++) sensors[i].update();
for (int i = 0; i < N; i++) {
Serial.print(sensors[i].name());
Serial.print("=");
Serial.print(sensors[i].value());
if (sensors[i].alarm()) Serial.print("!");
Serial.print(" ");
}
Serial.println();
}Now adding a 4th sensor is one line in the array. Loops do the work.
Step 4 — compare with the v0 (parallel arrays)
Open yesterday's parallel-arrays version. Count lines. The class version is usually similar in total but every piece has a clearer home:
- Initial values → constructor.
- Smoothing window → private field.
- Sensor reading →
update(). - Get the current value →
value().
And if you add a 2nd, 3rd, 10th sensor — they ALL get the same smoothing for free.
Step 5 — extract into a header file
Create SmoothedSensor.h with the class declaration. SmoothedSensor.cpp with the implementations. Your .ino just #include "SmoothedSensor.h". The class is now a tiny library — drop it into any sketch that needs smoothed analog reads.
Step 6 — connect to the OLED
Pass the sensor instance to a drawing function:
void drawSensor(int row, const SmoothedSensor& s) {
display.setCursor(0, row * 20);
display.print(s.name());
display.print(": ");
display.print(s.value());
if (s.alarm()) display.print(" !");
}
// in loop:
display.clearDisplay();
for (int i = 0; i < N; i++) drawSensor(i, sensors[i]);
display.display();The drawing function doesn't care about smoothing internals — it just asks the sensor for its current values. Each class minds its own business.
Try It Yourself 15 min
Goal: Add a setThreshold(int t) method so the alarm threshold can be changed at runtime.
Hint
void setThreshold(int t) { threshold_ = t; }Now light.setThreshold(900); works. The implementation detail (which member to update) is hidden.
Goal: Turn yesterday's Motor struct into a Motor class with setSpeed(int duty), brake(), coast() methods. Hide the IN1/IN2/EN pin numbers inside the class.
Hint
class Motor {
public:
Motor(int in1, int in2, int en)
: in1_(in1), in2_(in2), en_(en) {
pinMode(in1_, OUTPUT); pinMode(in2_, OUTPUT); pinMode(en_, OUTPUT);
}
void setSpeed(int duty) {
if (duty > 0) { digitalWrite(in1_, HIGH); digitalWrite(in2_, LOW); analogWrite(en_, duty); }
else if (duty < 0) { digitalWrite(in1_, LOW); digitalWrite(in2_, HIGH); analogWrite(en_, -duty); }
else { brake(); }
}
void brake() { digitalWrite(in1_, LOW); digitalWrite(in2_, LOW); analogWrite(en_, 0); }
private:
int in1_, in2_, en_;
};
Motor leftMotor(9, 8, 5);
Motor rightMotor(7, 6, 3);The Motor class is now indistinguishable in shape from Servo, Stepper, etc. Your chassis sketch can declare two motors and call methods. Encapsulation done.
Goal: Create a Buzzer class with chime() (preset 2-note ding-dong), tone(freq, ms) wrapper, and silence(). Add a Buzzer instance to your doorbell sketch.
Hint
Tiny class — 3 short methods. Wraps the existing tone() built-in. The benefit isn't the code reduction; it's that buzzer.chime() reads more clearly than 4 lines of tones and delays.
Mini-Challenge · Class-ify the multi-sensor build 10 min
- Move
SmoothedSensorinto its own.h+.cpppair. - Restructure your
.inoto be only setup() + loop() + the array of sensors. - Verify it still compiles and runs identically.
- Now
SmoothedSensor.his a reusable mini-library — copy it into any future sketch that needs smoothed reads.
You're building your own personal Arduino library. Every cluster H lesson adds one or two more pieces. By the end of L3 you have a dozen reusable classes.
Recap 5 min
Class = struct + methods + access control. Private data, public methods, constructor for setup. Each instance carries its own state. Drop a Servo, Stepper, WebServer, Motor in your sketch as easily as you declare an int. The mental shift: stop reaching for global variables; ask "is this state owned by something?" and put it inside that something. Tomorrow we write the canonical first class: a non-blocking blinker.
- Class
- A C++ user-defined type with both data (fields) and behaviour (methods). Default access is private.
- Method
- A function defined inside a class. Called on an instance with the dot operator.
- Constructor
- A special method with the class's name. Runs when an instance is created. Sets up initial state.
- Member initializer list
- The
: x_(x), y_(y)syntax after the constructor parameters. Initialises members before the body runs. - Public / private
- Access keywords.
public= part of the interface;private= internal. - Encapsulation
- Hiding internal data behind a method-only interface. Lets you change implementation without breaking callers.
- Instance
- A specific variable of a class type. Each has its own copy of the fields.
- Static member
- A field or method belonging to the class itself, not to an instance. Shared by all instances.
- Header / implementation split
- Class declaration in a
.hfile; method bodies in a.cpp. Lets multiple sketches share the same class.
Homework 5 min
- Convert one struct from your previous sketches into a class. Note line count delta. (Often the same; the value is clarity, not brevity.)
- Save
SmoothedSensor.h+.cppas a reusable mini-library. - Read ahead to ARD-L03-42 (Write a Blinker Class). Tomorrow we build a class that solves the "non-blocking blink" problem once and for all.
Bring back next class:
- Refactored class.
- SmoothedSensor library files.