T.TAO
Back to Blog
/5 min read/Programming

C++ #5 Objects and Classes

#C++#Programming#ComputerSystems

This note covers defining classes in C++, the lifetime of an object, and what these language features correspond to at the memory level.

A class is a struct with access control

In C++ the only linguistic difference between class and struct is the default access: struct defaults to public, class to private. Otherwise they are identical β€” both can have member functions, inheritance and virtual functions.

C++class Vector3 {
public:
    Vector3(float x, float y, float z) : x_(x), y_(y), z_(z) {}
    float length() const;

private:
    float x_, y_, z_;
};

By convention we use struct for "a few pieces of data travelling together" and class for "there is an invariant to maintain". The compiler does not care, but the reader does.

One fact worth internalizing: member functions take no space in the object. sizeof(Vector3) is 12 bytes β€” three floats, nothing else. Member functions compile to ordinary functions with one hidden first parameter, the this pointer. The length() above is closer to:

C++float Vector3_length(const Vector3* this);

Which also explains why a const member function cannot modify members: the const applies to the type this points at, so this becomes a const Vector3*.

Constructors

The initializer list is not syntactic sugar

C++class Widget {
public:
    Widget(const std::string& name) : name_(name) {}   // initialization
    // Widget(const std::string& name) { name_ = name; }  // assignment
private:
    std::string name_;
};

These two differ substantively. Every member is fully constructed before the constructor body begins. The initializer list calls the copy constructor directly; writing it in the body default-constructs an empty string first and then copy-assigns β€” one construction more than necessary.

For const members and reference members, assignment in the body is impossible at all; the initializer list is the only option.

And a trap: members are initialized in declaration order, regardless of the order you write them in the initializer list.

C++class Buffer {
public:
    Buffer(size_t n) : size_(n), data_(new char[size_]) {}  // looks fine
private:
    char*  data_;   // declared first, so initialized first
    size_t size_;   // declared second
};

This code is wrong. data_ is initialized first, at which point size_ still holds an indeterminate value. Keeping the declaration order and the initializer order identical is the cheapest way to never meet this bug.

Default and explicit constructors

Write any constructor of your own and the compiler stops generating the default one. You can ask for it back:

C++Widget() = default;

A single-argument constructor introduces an implicit conversion, which is usually not what you want:

C++class Buffer {
public:
    Buffer(size_t n);
};

void consume(Buffer b);
consume(42);   // compiles: 42 is implicitly converted to Buffer(42)

explicit blocks that conversion. The rule of thumb is: mark single-argument constructors explicit by default, unless you genuinely want the conversion, as std::string(const char*) does.

Destructors and object lifetime

The destructor runs automatically when the object leaves scope, in the reverse of construction order β€” stack semantics.

C++{
    A a;   // 1. a constructed
    B b;   // 2. b constructed
}          // 3. b destroyed   4. a destroyed

"Acquire the resource in the constructor, release it in the destructor" is RAII (Resource Acquisition Is Initialization), and it is one of the most valuable things C++ has that C does not. File handles, mutexes, memory β€” all of it can be managed this way:

C++class FileHandle {
public:
    explicit FileHandle(const char* path) : f_(std::fopen(path, "rb")) {}
    ~FileHandle() { if (f_) std::fclose(f_); }
private:
    std::FILE* f_;
};

Whether the function returns normally or throws, the destructor runs and the file is closed. What C solves with goto cleanup is here guaranteed by the language.

Destructors must not throw. If a destructor throws during stack unwinding, the program calls std::terminate outright. Since C++11 destructors are noexcept by default.

Static members

A static member belongs to the class rather than the object; every instance shares one copy.

C++class Counter {
public:
    static int count;                    // declaration
    static int get() { return count; }   // a static member function has no this
};
int Counter::count = 0;                  // definition, required outside the class

C++17 allows inline static int count = 0; directly inside the class, which removes that awkward out-of-class definition.

Static member functions have no this, so they cannot touch non-static members. Their common uses are factory functions, and callbacks for C interfaces that want a plain function pointer.

A memory-level view

The best way to understand a class is to look at what it becomes in memory:

  • A class with no virtual functions is laid out exactly as its members in declaration order plus alignment padding β€” identical to the equivalent struct.
  • Add a virtual function and the object gains a pointer to the vtable at its head (8 bytes on 64-bit). This is why adding a virtual function to a class holding one int makes sizeof jump from 4 to 16: 8 bytes of vtable pointer, 4 of int, 4 of padding.
  • sizeof an empty class is 1, not 0 β€” two distinct objects must have distinct addresses.

None of this matters for ordinary application code, but when you design data structures that need to pack tightly β€” particles, vertices, component arrays β€” it decides your cache behaviour directly. For more on layout, see C++ #2 Struct and Union.

  1. 01C++ #1 Data and Memory
  2. 02C++ #2 Struct and Union
  3. 03C++ #3 Pointers and Arrays
  4. 04C++ #4 Functions
  5. 05C++ #5 Objects and Classes
  6. 06C++ #6 Inheritance
  7. 07C++ #7 Copy Control and Operator Overloading
  8. 08C++ #8 Polymorphism
  9. 09C++ #9 C++11 New Features
  10. 10C++ #10 C++14 New Features
  11. 11C++ #11 C++17 New Features