Enums and Structs
Table of contents
- Enumerations
- Scoped Enumerations (
enum class) - Member variables
- Advanced Structs and Operator Overloading in C++
- Inheritance vs composition
- Member functions
- Static members
- Operator overloading
Enumerations
Some variables belong to a small, defined set; that is, they can have exactly one of a list of values. The enum type defines a set of (integer) values which a variable is permitted to have. For example, consider the days of the week:
enum Day { Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday };
The name of this type is Day, and the enumerators Sunday, Monday, etc., are assigned integer values starting from 0 by default. This means Sunday is 0, Monday is 1, and so on. You can also explicitly assign values to some or all of the enumerators if needed.
Example Program: Using enum
The following program demonstrates how to use the enum type:
#include <iostream>
#include <string>
enum Day { Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday };
std::string get_day_name(Day day) {
switch (day) {
case Sunday: return "Sunday";
case Monday: return "Monday";
case Tuesday: return "Tuesday";
case Wednesday: return "Wednesday";
case Thursday: return "Thursday";
case Friday: return "Friday";
case Saturday: return "Saturday";
default: return "Invalid day";
}
}
int main() {
Day today = Monday;
std::cout << "Today is " << get_day_name(today) << ".\n";
Day tomorrow = static_cast<Day>(today + 1);
std::cout << "Tomorrow will be " << get_day_name(tomorrow) << ".\n";
return 0;
}
Output
Today is Monday.
Tomorrow will be Tuesday.
Limitations of enum
While enum is simple and easy to use, it has several limitations:
- Global Scope of Enumerators: The enumerators (
Sunday,Monday, etc.) are placed in the global scope, which can lead to name collisions if multipleenums have the same enumerator names. - Implicit Conversion to Integer: Enumerators can be implicitly converted to integers, which can lead to unintended behavior. For example:
int day_value = Monday; // Implicitly converts to 1 - No Type Safety: Variables of different
enumtypes can be assigned to each other without any compiler error.
To address these limitations, C++ introduced enum class.
Scoped Enumerations (enum class)
The enum class type, also known as scoped or strongly typed enumeration, resolves the issues of plain enum. For example, the days of the week can be represented as follows:
enum class DayOfWeek : int { Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday };
The key differences are:
- Scoped Enumerators: The enumerators (
Sunday,Monday, etc.) are scoped to theenum classtype. This means you must qualify them with the type name, e.g.,DayOfWeek::Sunday. - No Implicit Conversion: Enumerators cannot be implicitly converted to integers. Explicit casting is required.
- Type Safety: Variables of different
enum classtypes cannot be assigned to each other.
Example Program: Using enum class
The following program demonstrates how to use the enum class type:
#include <iostream>
#include <string>
enum class DayOfWeek : int { Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday };
std::string get_day_name(DayOfWeek day) {
switch (day) {
case DayOfWeek::Sunday: return "Sunday";
case DayOfWeek::Monday: return "Monday";
case DayOfWeek::Tuesday: return "Tuesday";
case DayOfWeek::Wednesday: return "Wednesday";
case DayOfWeek::Thursday: return "Thursday";
case DayOfWeek::Friday: return "Friday";
case DayOfWeek::Saturday: return "Saturday";
default: return "Invalid day";
}
}
int main() {
DayOfWeek today = DayOfWeek::Monday;
std::cout << "Today is " << get_day_name(today) << ".\n";
DayOfWeek tomorrow = static_cast<DayOfWeek>(static_cast<int>(today) + 1);
std::cout << "Tomorrow will be " << get_day_name(tomorrow) << ".\n";
return 0;
}
Output
Today is Monday.
Tomorrow will be Tuesday.
Member variables
In many cases, it is practical to group related data into a single entity. For example, consider a schedule where each entry has a day of the week and a description. This can be represented using a struct that combines an enum for the day and a std::string for the description.
The following struct definition demonstrates how to create a composite type, containing two fields (also called member variables):
#include <string>
enum Day { Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday };
struct ScheduleEntry {
Day day;
std::string description;
};
This struct type is named ScheduleEntry. The fields of the struct are listed between braces, with their types (Day and std::string) followed by their names (day and description), separated by semicolons. There is also a mandatory semicolon after the closing brace.
Example Program: Using struct
The following program demonstrates how to create and use a ScheduleEntry object:
#include <iostream>
#include <string>
enum Day { Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday };
struct ScheduleEntry {
Day day;
std::string description;
};
std::string get_day_name(Day day) {
switch (day) {
case Sunday: return "Sunday";
case Monday: return "Monday";
case Tuesday: return "Tuesday";
case Wednesday: return "Wednesday";
case Thursday: return "Thursday";
case Friday: return "Friday";
case Saturday: return "Saturday";
default: return "Invalid day";
}
}
int main() {
ScheduleEntry meeting{ Wednesday, "Team meeting at 10 AM" };
std::cout << "Schedule Entry:\n";
std::cout << "Day: " << get_day_name(meeting.day) << "\n";
std::cout << "Description: " << meeting.description << "\n";
return 0;
}
Output
Schedule Entry:
Day: Wednesday
Description: Team meeting at 10 AM
Explanation
- Defining a
struct: TheScheduleEntrystruct groups aDayand astd::stringinto a single entity. - Creating an Instance: The
meetingvariable is initialized using uniform initialization syntax ({}). - Accessing Fields: The fields of the
structare accessed using dot-notation (meeting.dayandmeeting.description).
Exercises
- Modify the program to allow the user to input a day and a description, then display the schedule entry.
- Add a function to check if a given
ScheduleEntryfalls on a weekend (Saturday or Sunday). - Extend the
ScheduleEntrystruct to include a time field (e.g.,std::string time) and update the program accordingly.
Advanced Structs and Operator Overloading in C++
It may be desirable to create structs with multiple fields of the same type. An example of this is a simple two-dimensional Point class with fields called x and y, both being signed integers:
struct Point {
int x{}, y{};
};
Point p1{ 2, 3 };
As the field variables are of the same type they can be defined together, separated by a comma. The empty braces {} mean the same thing as for int variable definitions, x and y will get the default value of the this type, being zero.
A question you may ask is: “Why not simply use a two-element array type?”, such as:
using PointA = int[2];
PointA p2{ 4, 5 };
It’s a valid question, and at the machine level produces (most likely) similar code. In this case using a struct has the edge because it default-initializes, and having fields called p1.x and p1.y is more intuitive and less error-prone than having to use subscripting syntax p2[0] and p2[1].
Experiment:
-
Write a program to obtain the two fields of a previously defined
Pointobject fromcin. Don’t use any temporary variables. -
Modify this program to manipulate these fields in some way (such as multiplying them by two) and output them.
-
Write a function called
mirror_point()which reflects its input (of typePoint) in both the x- and y-axes. Experiment with passing by value andconst-reference (and returning the modifiedPoint), and by reference and by pointer (two differentvoidfunctions). Hint: for the last variant pass an address ofPointand access the fields withp->xandp->y, and see Chapter 4: Parameters by value and Parameters by reference for a refresher. Compare all four versions of this function for ease of comprehension and maintainability.
Inheritance vs composition
We have talked about composite types being made up of other types, and in fact types can be composed (nested) indefinitely, although many programmers would struggle to comprehend more than a few levels. The other way to create new types with characteristics of previously defined types is through inheritance, which is a key concept of OOP.
The following program defines an enum class called Color (feel free to add more color enumerators) and uses the same Point class to create a new Pixel class, which has both a location and a color being composed of both Point and Color fields.
// 06-pixel1.cpp : Color and position Pixel type through composition
#include <iostream>
#include <string_view>
using namespace std;
struct Point {
int x{}, y{};
};
enum class Color { red, green, blue };
struct Pixel {
Point pt;
Color col{};
};
string_view get_color(Color c) {
switch (c) {
case Color::red:
return "red";
case Color::green:
return "green";
case Color::blue:
return "blue";
default:
return "<no color>";
}
}
int main() {
Pixel p1;
cout << "Pixel p1 has color " << get_color(p1.col);
cout << " and co-ordinates " << p1.pt.x;
cout << ',' << p1.pt.y << '\n';
Pixel p2{ { -1, 2 }, Color::blue };
cout << "Pixel p2 has color " << get_color(p2.col);
cout << " and co-ordinates " << p2.pt.x;
cout << ',' << p2.pt.y << '\n';
}
Most, if not all, of the syntax should be familiar, however a few things to note:
-
PointandColormust be defined beforePixel, as the fields ofPixelare variables of these two types. -
Inside
Point,xandyare default-initialized (to zero). This means that inPixel,pthas aleady automatically default-initialized, whilecolhas to be initialized explicitly. -
The function
get_color()uses aColoras theswitch-variable, this is permitted becauseenumandenum classalways have an integer as the underlying type. -
This function returns a
std::string_view, althoughstd::stringorconst char *would work equally well. The data that thestd::string_viewrefers to is guaranteed to outlive the scope of the functionget_color()because they are read-only string literals; no copy is ever made. (The typestd::string_viewis covered in more detail in Chapter 7.) -
In
main()the variablep1is default-initialized toColor::redand0,0becuase of the default-initialization syntax in thestructdefinitions. The member variablep1.colisredbecause that is the enumeration with value zero (from default initlalization with{}). -
The variable
p2is set toColor::blueexplicitly at initialization, with the co-ordinates-1,2using nested initializer syntax. -
The member variables
xandyare members ofPoint,ptis a member ofPixel, so the full names ofp2’s two co-ordinates arep2.pt.xandp2.pt.y. This ahows how the member operator.can be chained in this way (it works for member functions, too), and operations remain fully type-safe.
Experiment:
-
Write a function
get_pixel()which returns information about aPixel, and remove the code duplication in calls tocoutfrommain(). Hint: the return type should bestd::stringand it should callget_color(); the code inmain()should read:cout << get_pixel(p1) << '\n'; -
Can you call
get_pixel()frommain()with a thirdPixel, without using a named variable? Hint: try to use initializer syntax in the function call. -
Change the default
Colorassigned top1to be<no color>. Hint: this is a simple change.
The next program accomplishes exactly the same as the previous one, producing the same output, and most likely very similar code of comparable efficiency. However it use inheritance instead of composition, which is indicated by a slightly different definition of Pixel:
// 06-pixel2.cpp : Color and position Pixel type through inheritance
#include <iostream>
#include <string_view>
using namespace std;
struct Point {
int x{}, y{};
};
enum class Color { red, green, blue };
struct Pixel : Point {
Color col{};
};
string_view get_color(Color c) {
switch (c) {
case Color::red:
return "red";
case Color::green:
return "green";
case Color::blue:
return "blue";
default:
return "<no color>";
}
}
int main() {
Pixel p1;
cout << "Pixel p1 has color " << get_color(p1.col);
cout << " and co-ordinates " << p1.x;
cout << ',' << p1.y << '\n';
Pixel p2{ { -1, 2}, Color::blue};
cout << "Pixel p2 has color " << get_color(p2.col);
cout << " and co-ordinates " << p2.x;
cout << ',' << p2.y << '\n';
}
A few things to note about this program:
-
The definition syntax
struct Pixel : Point {...};causesPixelto be derived fromPoint, meaningPixelinherits all ofPoint’s members.Pixelis therefore the derived class, whilePointis the base class. Sometimes the terms sub-class and super-class are used to refer to derived and base respectively. (Due to the fact we are using inheritance, it might be considered natural to refer to thestructtypes defined here as “classes”.) -
The
ptmember variable has been removed as it is no longer used -
The member variables
xandyare now direct members ofp1andp2, accessed usingp1.xetc.
Experiment:
-
What error message do you get it you change
p1.xback top1.pt.x. Would you understand what the compiler was saying? -
Modify
get_pixel()(written previously) to work with this program. Hint: The necessary changes should be very small. -
Now try to inherit from both
PointandColor(the syntax is:struct Pixel : Point, Color {...};). Does this work as expected? Why do you think this is?
The concepts of inheritance and composition introduced here pose the question: “Which is better?” The literature tells us that inheritance represents is-a modeling and composition represents has-a. So which is more accurate: Pixel is-a Point (with a color), or Pixel has-a Point (and a color)? Personally, I think the first one is a better description, and would suggest that is-a inheritance should be used wherever practical to do so. In Chapter 9 we will meet inheritance again when describing more complex classes.
Member functions
We have seen that the struct Point fields defined as int x{}, y{}; can be acessed as member variables of objects using dot-notation such as p1.x and p1.y. Changing the types of x and/or y (to double for example) does not cause any problems, but renaming the fields to something different causes a compilation error as we would be trying to reference former members of Point which no longer exist.
Our struct Point is said to have zero encapsulation; its internals are open to public view, inspection and modification. Sometimes this is acceptable, but more often we want to separate implementation from interface. Use of member functions can be a way to provide an interface between the user of a type (the programmer who uses objects of that user-defined type) and the implementor of that type (the programmer who created the user-defined type). This interface is a contract between the two, which should always be considered and designed carefully.
Let us consider what we need in order to rewrite Point with some degree of encapsulation. The following program is our, by now familiar, Point type with three member functions (sometimes called methods in other programming languages) defined in the body of the struct definition, that is, within the braces. These functions can read and write the values of the member variables x and y, and logically enough are known as getters and setters. They are said to be defined inline when written in full between the braces of the struct definition, and as such are often automatically inlined by the compiler. This means that there may well be no function call overhead, so performance considerations should not be a reason to disregard encapsulation. (Types with methods are ususally known as classes in other languages, from now on we will use this word to mean any C++ composite type declared with struct or class, except for enum class.)
// 06-point1.cpp : a Point type with getter and setters
#include <iostream>
using namespace std;
struct Point {
void setX(int nx) {
x = nx;
}
void setY(int ny) {
y = ny;
}
auto getXY() const {
return pair{x, y};
}
private:
int x{}, y{};
};
int main() {
Point p;
int user_x{}, user_y{};
cout << "Please enter x and y for Point:\n";
cin >> user_x >> user_y;
p.setX(user_x);
p.setY(user_y);
auto [ px, py ] = p.getXY();
cout << "px = " << px << ", py = " << py << '\n';
}
A few things to note about this program:
-
The member functions appear before the member variables in the definition of
Point; this is just a convention since members can in most cases appear in any order. The member function names have been written in camelCase which is a common convention. -
The member variables
xandyare in scope for all of the member functions, so there is no need to fully qualify them asthis->xandthis->y. -
The member function returns both
xandyas astd::pair. Theautoreturn type is used (it’s actuallystd::pair<int,int>) and is declaredconstbetween the (empty) parameter list and the function body. The use ofconstin this context means the member function promises not to modify any member variables (its own state). If you remember one thing about member functions, it should be to declare themconstwhenever they do not modify the object. -
The access specifier
private:is used before the member variablesxandywhich means that code outside the scope ofPoint(such as inmain()) cannot use them; they must use the getter and setters. -
The variable
pof typePointinmain()is default-initialized, other variants such asPoint p{ 0, 1 };are not possible (due toxandybeingprivate:), this would need a class constructor to be written (see Chapter 9). -
The
intsuser_x,user_y,pxandpyare all defined local tomain(). The member access operator.is used as inp.setX(user_x);to call the member functions; this is another use of dot-notation. -
The return type of member function
getXY()is read intopxandpyusing aggregate initialization, and these variables are outputted.
Experiment:
-
Write a function
setXY()which modifies both member variables ofPoint, and use this instead ofsetX()andsetY()inmain(). -
Write two functions
moveByX()andmoveByY()which add their parameter’s value to thexandymembers respectively -
Change
pairtotupleingetXY(). Does the code still compile? What does this indicate about the generality of aggregate initialization from return types? -
Try to modify
xwithingetXY(). What happens? Now try to return a modifiedxsuch asx+1instead. What happens now? Try both of these having removed theconstqualifier. -
Change the name of
xtosuper_xwithinPoint, remebering to change all of the member functions which usextoo. Does the code compile without any changes tomain()? What does this tell you about another advantage of separating implementation from interface?
Static members
In the context of a class definition, static member variables (sometimes called class variables) are similar to global variables, in that there is only one instance. They are said to be per-class as opposed to per-object; that is, regardless of how many objects of a struct (or class) there are. Also they are referred to outside of the struct definition with a double colon operator (::), not dot-notation.
The following program extends the Point class with two static member constants. The member functions setX() and setY() have been modified, try to guess what they now do from the code:
// 06-point2.cpp : a Point type with getter and setters which check for values being within range
#include <iostream>
using namespace std;
struct Point {
void setX(int nx)
{
if (nx < 0) {
x = 0;
}
else if (nx > screenX) {
x = screenX;
}
else {
x = nx;
}
}
void setY(int ny)
{
if (ny < 0) {
y = 0;
}
else if (ny > screenY) {
y = screenY;
}
else {
y = ny;
}
}
auto getXY() const {
return pair{x, y};
}
static const int screenX{ 639 }, screenY{ 479 };
private:
int x{}, y{};
};
int main() {
cout << "Screen is " << Point::screenX + 1 << " by " << Point::screenY + 1 << '\n';
Point p;
int user_x{}, user_y{};
cout << "Please enter x and y for Point:\n";
cin >> user_x >> user_y;
p.setX(user_x);
p.setY(user_y);
auto [ px, py ] = p.getXY();
cout << "px = " << px << ", py = " << py << '\n';
}
A few things to note about this program:
-
The static member variables
screenXandscreenYare declared bothstaticandconstand are assigned values within the definition ofPoint. -
These variables can be accessed directly from within
main()as they are defined before theprivate:access specifier. As they are read-only it is acceptable for them to be accessed directly. -
The default values of
xandy(zero) do not need to be changed as they fall within the permitted values. -
The class invariants
0 <= x <= screenXand0 <= y <= screenYare not easily able to be broken whenPointis written with setters which validate their input.
The goal of encapsulation is still achieved with screenX and screenY being directly accessible from within main() because they are constants. If screenX and screenY could be modified directly, this would no longer be the case, and a setter/getter pair (or similar) should be created. (A similar rule is allowing global constants, as opposed to variables, without restriction as neither data-races nor accidental reassignment can occur with constants.)
Experiment:
-
Refactor the code logic of
setX()andsetY()into a utility functionwithin()which is called by both. Declarewithin()to bestatic. Can you call it from withinmain()? How would this be accomplished, and is it desirable? -
Can you find a utility function from the Standard Library which does the same task as
within()? -
Remove the
constqualifier fromscreenXandscreenY’s definition. What other change is necessary? -
Move these two variables after the
private:access specifier, and write a getter/setter pair calledgetScreenXY()andsetScreenXY(). Modifymain()to accommodate this change. Is it easily possible to maintain the invariants of this type forPoints already created, that is existingPoints that are now outside the screen area?
Operator overloading
There are many operators in C++ and most of these can be adapted (or overloaded) to work with user-defined types. (Operators for built-in types are not able to be redefined.) Like many other features of the language their availability and flexibility should be approached with some degree of restraint.
Operator oveloading works in a similar way to function overloading, so some familiarity is assumed with this concept. C++ resolves operator calls to user-defined types, to function calls, so that r = a X b is resolved to r = operator X (a, b). (This is a slight simplification; where a is a user-defined type, the member function r = a.operator X (b) is used in preference, if available.)
The following program demonstrates the Point type, simplified back to its original form, with global operator+ defined for it:
// 06-point3.cpp : Point type with global operator+ defined
#include <iostream>
using namespace std;
struct Point{
int x{}, y{};
};
const Point operator+ (const Point& lhs, const Point& rhs) {
Point result;
result.x = lhs.x + rhs.x;
result.y = lhs.y + rhs.y;
return result;
}
int main() {
Point p1{ 100, 200 }, p2{ 200, -50 }, p3;
p3 = p1 + p2; // use overloaded "operator+"
cout << "p3 = (" << p3.x << ',' << p3.y << ")\n";
}
A few things to note about this program:
-
The return type of the
operator+we define is returned by value; it is a new variable. The return value is declaredconstin order to prevent accidental operations on a temporary, such as:(p1 + p2).x = -99; -
The parameters of this function are passed in by
constreference. The nameslhsandrhsare very common (for the left-hand-side and right-hand-side to the operator at the call site inmain()). -
The function
operator+needs to access the member variables of the parameters passed in. -
The new values
result.xandresult.yare computed independently, as might be expected. -
The statement
p3 = p1 + p2;invokes a call tooperator+automatically.
Experiment:
-
Rewrite
operator+to avoid the need for a named temporary variableresult. -
Write an
operator-and call it frommain().
It is usual to write operators as global (or free, or non-member) functions when they do not need to access private: parts of the types which they operate on. This is not a problem for member function operators as they implicitly have access to all parts of both themselves and the variable they operate on.
The simplified result of these conventions is demonstrated in the following program:
// 06-point4.cpp : Point type with global operator+ and member operator+= defined
#include <iostream>
using namespace std;
struct Point{
int x{}, y{};
Point& operator+= (const Point& rhs) { // member operator +=
x += rhs.x;
y += rhs.y;
return *this;
}
};
const Point operator+ (const Point& lhs, const Point& rhs) { // non-member operator+
Point result{ lhs };
result += rhs;
return result;
}
int main() {
Point p1{ 100, 200 }, p2{ 200, -50 }, p3;
p3 = p1 + p2;
cout << "p3 = (" << p3.x << ',' << p3.y << ")\n";
}
A few things to note about this program:
-
The
main()function and its output are the same as for the previous program. -
The member function
operator+=takes one parameter namedrhsand modifies its own member variables. It returns a reference to aPoint, this being itself. One of the rare uses of thethispointer, dereferenced here with*, is shown here without further explanation. -
The global
operator+makes a copy oflhsand then calls (member)operator+=on this with parameterrhs. (Both of therhs’s are the same variable as they are passed by reference.) -
Global
operator+does not directly access the member variables of either of its parameters. -
The variable
resultis then returned byconstvalue, as before.
Experiment:
-
Modify this program to implement and test
operator-=andoperator-. -
Now modify the program to use the encapsulated version of the
Pointfrom the program06-point2.cpp. What difficulty would you encounter if you tried using only globaloperator+? -
Add a
staticfunction to calculate the diagonal distance between twoPoints and return it as adouble. Consider how to implementoperator/to calculate this value, and whether this would be a suitable use of OO.
This course is largely based on the material from Richard Spencer’s repository, where he shares his knowledge and passion for modern C++ programming.