C++ Structs

7 minute read

Published:

C++ structs are useful for a number of things, as simple POD objects or for more complex needs.

Overview

C++ structs are essentialy a class. The only difference is that all members are public by default, meaning they can be accessed anywhere in a program.

Defining

In order to use a struct, you first need to define it and its members. A struct member can be just about any other object in C++.

Some examples include:

  • integer (integral) types
    • booleans
    • ints
    • chars
  • pointers
  • other complex objects
    • structs
    • classes
    • unions
    • functions

etc, etc…

This is quite different from base C where a struct is simply an aggregate of data. C++ was designed with object oriented programming in mind, so understandably, structs reflect this design choice. They are primarly still used for data aggregation, however, they offer much more versatility while allowing for backwards compatability with APIs exposed to base C code.

// Struct definition
struct Foo {
  int x;
  int y;
}

// Structs can be constructed with designated initializers
Foo foo{
  .x = 4,
  .y = 5,
};

// We can also make a struct with a constructor function
struct Bar {
  // This function is called whenever
  // we make a new Bar object
  Bar(int x, int y) : x(x), y(y) {}

  // This is the struct destructor, 
  // it is called whenever the object is destroyed
  ~Bar() {
    std::cout << "I am dying!" << std::endl;
  }

  // The objects members
  int x;
  int y;
}

// We can now create our object with
// list initialization
Bar bar{4, 5};

Using Structs

Once your struct is created, you can access its members anywhere in a program.

#include <iostream>

int main() {
  // We first construct our object
  Foo foo{
    .x = 4,
    .y = 5,
  };
  // We can access the member and print to terminal
  std::cout << foo.x << std::endl;

  // We can even use them in expressions
  std::cout << foo.y + 5 << std::endl;
  return 0;
}

Private Members

Members in a struct can be set to private, why you would do this, I am not sure. But you definitely can.

#include <iostream>
struct PrivateFoo {
  PrivateFoo(int x): x(x) {}
  private:
    int x;
}

int main() {
  PrivateFoo pf{4};

  // This would not compile!
  std::cout << pf.x << std::endl;
  return 0;
}

Advanced Structs

As mentioned previously, structs can simply act as POD containers but they can also be used for much more complex tasks.

For example, in order to use a std::unordered_map, the objects used as keys need to be hashed. Hashing is the process of taking an object and creating some kind of numerical code to represent the object. However, the way a hash map works is different depending on the compiler, as each implementation differs in the design and behavior.

With that said, for the builtin objects in C++, most can simply use the built in std::hash. However, if you create a custom object, you are required to create your own hashing function.

Let’s say we wanted to create a KeyChord structure that contains an int key, and a modifier key such as CTRL. This can allow us to combine keys together for more complex inputs during event polling.

// We create a small enum that inherits 
// from the uint_8 to make a bit mask
enum Mod : uint8_t {
    // 0000
    MOD_NONE = 0,
    // 0001
    MOD_CTRL = 1 << 0,
    // 0010
    MOD_SHIFT = 1 << 1,
    // 0100
    MOD_ALT = 1 << 2,
    // 1000
    MOD_SUPER = 1 << 3,
};

// We create a small POD struct to store the key type and if
// it is modified or not using our bit mask
struct KeyChord {
    int key;
    Mod mods;
};

// Here is where the struct magic comes from
// We create something known as a functor as our hash func
struct KeyChordHash {
    std::size_t operator()(const KeyChord &c) const {
    /*
      * The internal hashing funtions used 
      * for ints are quite good so we can
      * simply use them on the underlying int and enum
      *
      * The ^ XOR bitwise operator is standard for 
      * combining two hash values and we bit shift once to 
      * the left since it reduces hash collisions
    */
        return std::hash<int>{}(c.key) ^ 
          (std::hash<int>{}(int(c.mods)) << 1);
    }
};

/*
  * We can now populate our hash map
  *
  * We make an alias for a function that 
  * takes an App object reference and returns a void
  *
  * This can be useful for binding behavior to 
  * our key combinations
*/
using Command = std::function<void(App &)>
std::unordered_map<KeyChord, 
                  Command, 
                  KeyChordHash> chordmap_;

/*
  * To use it we can now do something like this
  * We store lambdas in our hashmap and whenever
  * a key is presed later on, we can search the
  * map and dispatch the function
  *
  * This way we don't have to write one massive if
  * or switch statement for all our keys
*/
void bind() {
  chordmap_[{KEY_SPACE, MOD_NONE}] = 
    [](App &app) { app.fire_laser_beam(); };
  chordmap_[{KEY_Q, MOD_CTRL}] = 
    [](App &app) { app.quit_game(); };
}

There is quite a bit going on this snippet but in a nutshell, we create our KeyChord struct which simply stores an int (ie. enum for a key) and a modifer key (ie. CTRL, SUPER, etc). We can then make an unordered map that can store the KeyChord and a Command which is an alias for a function. We make sure to pass in the hashing functor and our map is ready to go!

But what’s a functor? It stands for function object, a class or struct that can be called as if it were a function. A bit complicated, but incredibly useful. If we consider normal functions, they don’t typically “remember” state, barring the use of static variables. However, a functor can remember what happens in between calls, greatly improving flexability. Functors are quite common in the C++ Standard Template Library algorithms for this reason, and they can be found in other successful libraries such as cxxopts for command line argument parsing.

The way to create a functor is to overload the ( ) operator. Operator overloading is one of the most useful things in all of object oriented programming in my opinion, as it allows for some fairly neat behavior. For example, the ggplot graphics library for the R programming language uses operator overloading for the + operator when creating plots. The pathlib module in Python does something similar with / operator to join PosixPath objects or strings together.

Suffice to say, structs can become quite a bit more powerful with operator overloading. As a matter of fact, lambdas are basically syntactic sugar for an anonymous functor.

// Lambda expression
auto lambda = [](int x, int y) { return x + y; };

// Equivalent functor
struct Functor {
    int operator()(int x, int y) const {
        return x + y;
    }
};