C++ struct vs class: The One Language Difference and the Conventions Built on It

Key takeaways

In C++, struct and class declare exactly the same kind of type. The only difference is the default access for members and base classes (public for struct, private for class). Everything else, including whether a type is an aggregate, trivially copyable or C-compatible, depends on what you put in the type, not on the keyword.

In C++, struct and class declare the same thing: a class type. A struct can have constructors, private members, virtual functions, base classes and templates, and a class can be a plain bag of public fields. The language difference is one rule about default access, and it applies in two places.

Everything else people associate with “struct vs class”, such as C compatibility, POD-ness or brace initialization, is decided by the contents of the type, not by the keyword. This article separates the language rule from the conventions and shows where each one matters.

The only language difference: default access

Default for…structclass
Memberspublicprivate
Base classespublic inheritanceprivate inheritance
#include <iostream>

struct Point {        // members public by default
    int x, y;
};

class Point2 {        // members private by default
    int x, y;
public:
    Point2(int x, int y) : x(x), y(y) {}
    int getX() const { return x; }
};

int main() {
    Point p;
    p.x = 10;         // OK
    Point2 p2(10, 20);
    // p2.x = 10;     // error: 'int Point2::x' is private within this context
    std::cout << p.x + p2.getX() << '\n';   // prints 20
}

Once you write an access specifier explicitly, the keyword no longer matters for those members. struct S { private: int x; }; and class S { int x; }; are the same type definition.

The default that actually bites: inheritance

The member default is visible every time you use the type, so mistakes show up quickly. The base-class default is the one that surprises people, because it is decided by the keyword of the derived type:

class Base {
public:
    void foo() {}
};

struct DerivedStruct : Base {};   // public inheritance by default
class DerivedClass : Base {};     // private inheritance by default

int main() {
    DerivedStruct ds;
    ds.foo();          // OK
    Base* b1 = &ds;    // OK

    DerivedClass dc;
    dc.foo();          // error
    Base* b2 = &dc;    // error
}

g++ 10 reports:

error: 'void Base::foo()' is inaccessible within this context
error: 'Base' is an inaccessible base of 'DerivedClass'

With private inheritance, DerivedClass still contains a Base, but outside code cannot see that relationship, so the implicit conversion to Base* that polymorphism relies on no longer compiles. The fix is to say class DerivedClass : public Base.

In my experience this shows up most often when a type is changed from struct to class during a cleanup, “because it has methods now”. The member access is usually fixed by adding public:, but the inherited base silently becomes private, and the error appears somewhere else entirely, at a call site that passes the object as a Base&. Writing public on every base class, even in a struct, makes the intent explicit and survives that kind of edit.

Keyword-independent properties: aggregate, trivial, standard layout

The properties that decide whether a type can be brace-initialized field by field, copied with memcpy, or shared with C code are all determined by the members, not by the keyword. The type traits make this easy to check:

#include <iostream>
#include <type_traits>

struct Point { int x, y; };

class Hidden {          // all data members private, no constructors
    int x, y;
public:
    int sum() const { return x + y; }
};

class Mixed {           // public and private data members
public:
    int x;
private:
    int y;
};

struct WithCtor {
    int x, y;
    WithCtor(int a, int b) : x(a), y(b) {}
};

struct WithVirtual {
    int x;
    virtual ~WithVirtual() = default;
};

template <typename T>
void report(const char* name) {
    std::cout << name
              << ": aggregate=" << std::is_aggregate_v<T>
              << " trivially_copyable=" << std::is_trivially_copyable_v<T>
              << " trivial=" << std::is_trivial_v<T>
              << " standard_layout=" << std::is_standard_layout_v<T> << '\n';
}

int main() {
    report<Point>("Point      ");
    report<Hidden>("Hidden     ");
    report<Mixed>("Mixed      ");
    report<WithCtor>("WithCtor   ");
    report<WithVirtual>("WithVirtual");
}

Output with g++ 10.3, -std=c++17:

Point      : aggregate=1 trivially_copyable=1 trivial=1 standard_layout=1
Hidden     : aggregate=0 trivially_copyable=1 trivial=1 standard_layout=1
Mixed      : aggregate=0 trivially_copyable=1 trivial=1 standard_layout=0
WithCtor   : aggregate=0 trivially_copyable=1 trivial=0 standard_layout=1
WithVirtual: aggregate=0 trivially_copyable=0 trivial=0 standard_layout=0

A few things in that table contradict common rules of thumb:

  • Private members do not prevent standard layout. Hidden is standard-layout and trivial, so it is what used to be called POD. Standard layout requires that all non-static data members have the same access control; it is Mixed, with both public and private data, that loses it.
  • Private data members do prevent aggregate initialization. Hidden h{1, 2}; does not compile. That is the practical effect of hiding data: you need a constructor.
  • A user-written constructor does not make a type unsafe to memcpy. WithCtor is still trivially copyable, because copying is governed by the copy/move constructors, assignment operators and destructor, which are all still implicit. What it loses is triviality (no trivial default constructor) and aggregate status.
  • A virtual function changes everything. The object now contains a vtable pointer, so it is neither trivially copyable nor standard-layout, and copying it with memcpy is undefined behavior.

“POD” itself is on its way out. std::is_pod was deprecated in C++20, and g++ says so directly when you use it with -std=c++20:

warning: 'std::is_pod_v<P>' is deprecated: use is_standard_layout_v && is_trivial_v instead [-Wdeprecated-declarations]

When you need a property, ask for that property. For memcpy or std::bit_cast you usually want is_trivially_copyable_v. For a type whose layout must match a C struct, you want is_standard_layout_v.

Performance: the keyword costs nothing

Access control is checked by the compiler and leaves nothing in the object file. A struct and a class with the same members, in the same order and with the same access pattern, have the same size, alignment and layout, and member access compiles to the same instructions.

What can affect performance is the same set of properties as above. Trivially copyable types can be copied in bulk, and the calling conventions on common platforms can pass small trivially copyable types in registers instead of through memory. The exact rules differ between ABIs (the Itanium C++ ABI used by GCC and Clang, and the MSVC ABI, use different criteria), but none of them looks at whether you wrote struct or class.

Where the keywords are not interchangeable

There are a few spots in the grammar where only one of the keywords works, which has nothing to do with the struct/class distinction for types:

  • Template type parameters are introduced with typename or class, never struct. template <struct T> is not an error you might expect: g++ parses it as a non-type parameter of class type T, and in C++17 mode reports non-type template parameters of class type only available with '-std=c++2a'.
  • Scoped enumerations accept both: enum class Color and enum struct Color mean the same thing. enum class is the one everyone writes.

Conventions: choosing the keyword to express intent

Since the compiler does not care, the choice is about telling the reader what kind of type this is. The two widely used style guides agree in substance:

  • The C++ Core Guidelines say to use class if the type has an invariant and struct if the data members can vary independently (C.2), and to use class rather than struct if any member is non-public (C.8).
  • The Google C++ Style Guide says to use a struct only for passive objects that carry data, and a class for everything else.

In practice that gives a simple rule:

// Data that can vary freely: struct
struct RetryOptions {
    int maxAttempts = 3;
    int backoffMs = 100;
    bool jitter = true;
};

// A type that protects an invariant: class
class Account {
public:
    explicit Account(long long cents) : balance_(cents) {}
    bool withdraw(long long cents) {
        if (cents <= 0 || cents > balance_) return false;
        balance_ -= cents;
        return true;
    }
    long long balance() const { return balance_; }
private:
    long long balance_;   // invariant: never negative
};

RetryOptions has no rule connecting its fields, and it stays an aggregate, so callers can write connect({.maxAttempts = 5}) with C++20 designated initializers. Account has a rule, “the balance is never negative”, which only holds if all modifications go through withdraw.

The failure mode the convention guards against is gradual: a type starts as a struct of plain fields, gains a method that assumes some relationship between them, and the fields stay public. Nothing stops other code from writing account.balance = -1000; and the method’s assumption is quietly broken. When I add the first member function that depends on an invariant, that is the point where I make the data private, and switching the keyword to class at the same time makes the change visible in review.

Mixing keywords across declarations

Because both keywords declare the same kind of type, this is valid C++ and g++ accepts it silently:

class Widget;              // forward declaration says class
struct Widget { int x; };  // definition says struct

MSVC warns about it with C4099 (“type name first seen using ‘class’ now seen using ‘struct’”). The warning exists for a practical reason: MSVC’s name mangling encodes whether a type was declared with class or struct, so a function declared with a parameter of type class Widget* in one header and struct Widget* in another can produce two different mangled names and an unresolved-symbol error at link time. Clang has an equivalent warning, -Wmismatched-tags. Picking one keyword per type and using it in every declaration avoids the issue on all compilers.