14: Classes

UC Irvine - Fall ‘22 - ICS 45C

Quick list of things I want to talk about:

  • Members
  • Methods
  • Constructors
  • Public
  • Protected
  • Private
    • Contrast to structs
  • Destructors
    • Memory leaks
  • Const fields
  • Const methods

Expanded notes:

In the last set of notes we created a library that managed a linked list. We defined a struct to hold the values, and functions that can manipulate the pointers. But, we’re assuming that the user will make correct use of those, and that they will call functions as needed. For example, we’re hoping they call deleteList once they’re done to prevent any memory leaks.

One way to solve that problem and make sure things are working as we intended, is to package everything into a class. A class will combine all data with the appropriate methods, and should give the user a nice way of using its functionalities.

Although in C++ structs can also combine data with methods, structs cannot control the access appropriately, as we’ll discuss later in this set of notes.

Creating a class

The syntax to create a class is as follows:

class [CLASS NAME] {
type0 name0;
type1 name1;
type2 name2;
...
};

where typeX is the type we want to use for nameX. nameX could be either a variable name, or a function signature.

Let’s use a linked list a running example for this note:

struct LinkedListNode {
  int value;
  struct LinkedListNode* next;
};

class LinkedList {
 public:
  std::string name;
  LinkedListNode* head;
  LinkedListNode* tail;
  void addToFront(int x);
  void addToBack(int x);
  void printList();
};

First, we created a LinkedListNode struct that will store the data for the list. It has a value for the current node and a pointer to the next node.

Then, we create our LinkedList class. This class contains;

  • a string with a name for this list;
  • two pointers, one for the head of our list and one for the tail of our list;
  • a function that adds a new node to the front of the list;
  • a function that adds a new node to the back of the list;
  • a function that prints the list.

The variables are usually called the class members. In this case, name, head, and tail would be members of LinkedList.

The functions are usually called the class methods. So here we have addToFront, addToBack, and printList as methods of LinkedList. Notice how we only have the prototypes of our methods, we have not implemented them yet. Similar to our previous library, we will split our class into a header and a source file.

Finally, we add public so the user can access all of this information. We’ll discuss more about it later in the note.

Header and source files

To split our class into a header and source file, we’ll follow the same idea we’ve been using: header file contains things the user needs to know (e.g., structure definitions, function prototypes), and the source file contains the implementation specific things.

So, our header file could be like this:

#ifndef __LINKEDLIST_H__
#define __LINKEDLIST_H__

#include <string>

namespace ICS45C {
namespace Structures {

struct LinkedListNode {
  int value;
  struct LinkedListNode* next;
};

class LinkedList {
 public:
  std::string name;
  LinkedListNode* head;
  LinkedListNode* tail;
  void addToFront(int x);
  void addToBack(int x);
  void printList();
};

}  // namespace Structures
}  // namespace ICS45C

#endif

And our source file could be like this:

#include "linkedlist.h"
#include <iostream>

namespace ICS45C {
namespace Structures {

void LinkedList::addToFront(int x) {
  // Create new node.
  LinkedListNode* newNode = new LinkedListNode;
  newNode->value = x;
  newNode->next = head;  // points to old head.

  // Update head/tail pointers.
  if (head == nullptr) {
    head = tail = newNode;
  } else {
    head = newNode;
  }
}

void LinkedList::addToBack(int x) {
  // Create new node.
  LinkedListNode* newNode = new LinkedListNode;
  newNode->value = x;
  newNode->next = nullptr;  // points to null since it's new tail.

  // Update head/tail pointers.
  if (tail == nullptr) {
    head = tail = newNode;
  } else {
    tail->next = newNode;
    tail = newNode;
  }
}

void LinkedList::printList() {
  LinkedListNode* ptr = head;
  std::cout << "Values inside '" << name << "':\n  ";
  while (ptr) {
    std::cout << ptr->value << " -> ";
    ptr = ptr->next;
  }
  std::cout << "nullptr\n";
}

}  // namespace Structures
}  // namespace ICS45C

Note how we add the namespaces around the source file too, then we don’t need to have the complete name in the function names! However, notice how we still need the class name for the methods we’re defining here in the source file. We asked you to use the full names for p3 so this syntax doesn’t surprise you here :)

Also regarding p3, you should not try to save a tail pointer and copy our addToBack method from this example. This only works because we are the only one manipulating the pointers, which is not true for the project!

Constructors and Destructor

Now, we have implemented our methods and know how to add things to the list. But what’s the initial value for head, tail, and name? Also, what happens when our program exits? What about all those new memory we have requested?

We can define those with constructors and destructors.

Constructors

If a class has members, it should have a special function that’s called a constructor. A constructor initializes the members of the class and prepares anything the class needs to work properly later on. A constructor is defined with the same name as the class.

So we modify our header file:

class LinkedList {
 public:
  std::string name;
  LinkedListNode* head;
  LinkedListNode* tail;
  LinkedList();  // constructor without parameters
  LinkedList(std::string name); // constructor with a string parameter
  void addToFront(int x);
  void addToBack(int x);
  void printList();
};

And add the implementation to our source file:

LinkedList::LinkedList() : LinkedList("ICS45C LinkedList") {}

LinkedList::LinkedList(std::string name) : name(name) {
  head = nullptr;
  tail = nullptr;
}

A few things to note about the syntax to implement a constructor. First, there is no name/type, it is somewhat a combination of both. So you just have ClassName::ClassName, in our case, LinkedList::LinkedList. Then, after the parameters, we have a single colon :, and we initialize some variables.

For our first constructor, we are actually calling our second constructor with a default name parameter.

For our second constructor, we initialize the value of our member name with the value of our parameter name. Then, in the body we are initializing head and tail to be nullptrs. We can initialize either using the colon notation in the signature or in the body for now, we will have some restrictions in a little bit.

Destructor

The last thing we need to do is make sure we manage the memory we use. Since we allocated new memory when adding new nodes, we need to free the memory when our class object is deleted. That way we can prevent memory leaks in our class. To do that, we need to define a destructor.

In our header file:

class LinkedList {
 public:
  std::string name;
  LinkedListNode* head;
  LinkedListNode* tail;
  LinkedList();
  LinkedList(std::string name);
  ~LinkedList();  // destructor
  void addToFront(int x);
  void addToBack(int x);
  void printList();
};

In our source file:

LinkedList::~LinkedList() {
  while (head) {
    tail = head->next;
    delete head;
    head = tail;
  }
  head = tail = nullptr;
}

A few things to note about the syntax to implement a destructor. Similar to a constructor, the name/type is somewhat combined into ClassName::~ClassName, in our case, LinkedList::~LinkedList. Then, in the body we delete all the memory we have allocated.

If your class doesn’t have any dynamic memory (you don’t use new), you might not need a destructor.

Creating an instance

Now we have a complete class, we can construct an object, destroy an object, and use all methods and access all members.

Let’s try it out:

int main() {
  LinkedList list0, list1("List 1"), list2{"Another list"};
  
  list0.addToFront(1);
  list0.printList();

  list1.addToBack(2);
  list1.printList();

  list2.addToFront(4);
  list2.addToBack(3);
  list2.printList();

  return 0;
}

Our first object list0 uses the constructor that doesn’t take any parameters, so its name is “ICS45C LinkedList”. Then, we use parentheses to call a constructor for list1 and give it a different name. Finally, we use the {} initializer for list2 and give it a third name.

Running this code should output:

Values inside 'ICS45C LinkedList':
  1 -> nullptr
Values inside 'List 1':
  2 -> nullptr
Values inside 'Another list':
  4 -> 3 -> nullptr

Access control

However, the user still has access to all class members and methods. So they could do something like this:

cout << list0.head->value;

or…

list0.head = nullptr;

which would mess up our list and leak some memory. We should prevent that!

So this is the main difference between structs and classes, we get to choose which parts are public and which parts are private. Structs can have members, methods, constructors, and destructors, but that’s all public. With a class we can choose certain method/members to be public, and certain methods/members to be private.

We do this by modifying the class definition in our header file.
In our example, let’s make all methods public, and make all members private:

class LinkedList {
 private:  // user cannot use
  std::string name;
  LinkedListNode* head;
  LinkedListNode* tail;

 public:  // user can use
  LinkedList();
  LinkedList(std::string name);
  ~LinkedList();
  void addToFront(int x);
  void addToBack(int x);
  void printList();
};

We don’t need to modify the source file.

Now, the user can still create objects from our class, and use the methods, but if they try to use name, head, or tail, the compiler will give them an error.

Constants

There are two types of constants that we need to think about. Constant members inside our classes, and constant instances of our class.

Constant members

Constant members receive a value when the constructor is called and cannot be modified after that. They need to be initialized with the : syntax.

Let’s make the name of our LinkedList a constant:

class LinkedList {
 private:
  const std::string name; // added const
  LinkedListNode* head;
  LinkedListNode* tail;

 public:
  LinkedList();
  LinkedList(std::string name);
  ~LinkedList();
  void addToFront(int x);
  void addToBack(int x);
  void printList();
};

Now, our constructor needs to initialize name like this:

LinkedList::LinkedList(std::string name) : name(name) {
  head = nullptr;
  tail = nullptr;
}

Since it’s a const, it cannot be initialized in the body of the constructor like head and tail. You could initialize those in the signature line too:

LinkedList::LinkedList(std::string name)
    : name(name), head(nullptr), tail(nullptr) {}

So, if the member is a const, it needs to be initialized before the body of the constructor. If it’s not a const, then you get to choose.

Now, other methods can still use the value of name, they simply cannot change it.

Constant instances

Constant instances are also allowed. So a user might create a const LinkedList:

const LinkedList emptyList("Empty List");

In this case it doesn’t make too much sense, since the whole idea is to add new elements as we need. But it is possible to create one.

This creates a new challenge: what methods are const-safe and which ones are not?

  • Would it make sense to try to addToFront in a constant list? Probably not.
  • Would it make sense to printList a constant list? Probably, you still want to see what’s inside.

However, if you try creating an empty list and calling any of the methods we have defined, you will get an error saying it’s not const-safe. By default, all methods are not const-safe, the compiler assumes every method will modify things inside the object.

If you want to allow a method to work with constant instances, you will have to explicitly mark it as const safe:

class LinkedList {
 private:
  const std::string name;
  LinkedListNode* head;
  LinkedListNode* tail;

 public:
  LinkedList();
  LinkedList(std::string name);
  ~LinkedList();
  void addToFront(int x);
  void addToBack(int x);
  void printList() const;  // added const
};

Note that this changes the signature of the method! So we do need to change the source file to match:

void LinkedList::printList() const {  // added const
  LinkedListNode* ptr = head;
  std::cout << "Values inside '" << name << "':\n  ";
  while (ptr) {
    std::cout << ptr->value << " -> ";
    ptr = ptr->next;
  }
  std::cout << "nullptr\n";
}

Now the user can use this method with a const instance and it should work.

For example, if you run this code:

int main() {
  const LinkedList emptyList("Empty List");
  emptyList.printList();
  
  return 0;
}

it should output:

Values inside 'Empty List':
  nullptr

Operator overloading

When creating your own classes, you can define how it should work with existing operators. For example, we could make our LinkedList class printable, so we can use cout << myList; directly, instead of calling printList all the time.

To do that, we would define a public method like this in the header:

friend std::ostream& operator<<(std::ostream& os, const LinkedList& ll);

And implement it like this in the source file:

std::ostream& operator<<(std::ostream& os, const LinkedList& ll) {
  LinkedListNode *ptr = ll.head;
  os << "Values inside '" << ll.name << "':\n  ";
  while (ptr) {
    os << ptr->value << " -> ";
    ptr = ptr->next;
  }
  os << "nullptr\n";
  return os;
}

Note that this method was defined as a friend. This means that it can access the private members of LinkedList, even though it is a method from ostream.

Now, we can just use cout << myList;. For example:

int main() {
  LinkedList myList;

  myList.addToFront(1);
  myList.addToFront(2);
  myList.addToBack(3);

  cout << myList;
  
  return 0;
}

This should output:

Values inside 'ICS45C LinkedList':
  2 -> 1 -> 3 -> nullptr

We can also modify our printList function to just use this new operator:

void LinkedList::printList() const { std::cout << *this; }

this is an implicitly declared pointer that gives access to the object itself. So by using cout << *this; inside this function, we have the same behavior for cout << myList; and myList.printList();. By doing this, if you want to make any changes to how your list is printed, you would only need to change things in a single place. It’s also fine to have them behaving differently though.

You can overload pretty much all the operators in C++ for your class. You could make ++myList increment all nodes by 1; you just need to define an operator++ method and implement it. You could make myList1 + myList2 combine all their nodes; you just need to define an operator+ method and implement it.

To see all operators you can overload, check here: https://en.cppreference.com/w/cpp/language/operators
To see a few more examples, you can check here: https://www.tutorialspoint.com/cplusplus/cpp_overloading.htm

Complete LinkedList example

Here’s a complete example of each file we implemented:

linkedlist.h

#ifndef __LINKEDLIST_H__
#define __LINKEDLIST_H__

#include <iostream>
#include <string>

namespace ICS45C {
namespace Structures {

struct LinkedListNode {
  int value;
  struct LinkedListNode* next;
};

class LinkedList {
 private:
  const std::string name;
  LinkedListNode* head;
  LinkedListNode* tail;

 public:
  LinkedList();
  LinkedList(std::string name);
  ~LinkedList();
  void addToFront(int x);
  void addToBack(int x);
  void printList() const;
  friend std::ostream& operator<<(std::ostream& os, const LinkedList& ll);
};

}  // namespace Structures
}  // namespace ICS45C

#endif

linkedlist.cpp

#include "linkedlist.h"

namespace ICS45C {
namespace Structures {

LinkedList::LinkedList() : LinkedList("ICS45C LinkedList") {}

LinkedList::LinkedList(std::string name)
    : name(name), head(nullptr), tail(nullptr) {}

LinkedList::~LinkedList() {
  while (head) {
    tail = head->next;
    delete head;
    head = tail;
  }
  head = tail = nullptr;
}

void LinkedList::addToFront(int x) {
  // Create new node.
  LinkedListNode* newNode = new LinkedListNode;
  newNode->value = x;
  newNode->next = head;  // points to old head.

  // Update head/tail pointers.
  if (head == nullptr) {
    head = tail = newNode;
  } else {
    head = newNode;
  }
}

void LinkedList::addToBack(int x) {
  // Create new node.
  LinkedListNode* newNode = new LinkedListNode;
  newNode->value = x;
  newNode->next = nullptr;  // points to null since it's new tail.

  // Update head/tail pointers.
  if (tail == nullptr) {
    head = tail = newNode;
  } else {
    tail->next = newNode;
    tail = newNode;
  }
}

void LinkedList::printList() const { std::cout << *this; }

std::ostream& operator<<(std::ostream& os, const LinkedList& ll) {
  LinkedListNode* ptr = ll.head;
  os << "Values inside '" << ll.name << "':\n  ";
  while (ptr) {
    os << ptr->value << " -> ";
    ptr = ptr->next;
  }
  os << "nullptr\n";
  return os;
}

}  // namespace Structures
}  // namespace ICS45C

test-class-main.cpp

#include <iostream>
#include "linkedlist.h"

using std::cout;
using ICS45C::Structures::LinkedList;

int main() {
  LinkedList myList0("Hello");
  myList0.addToFront(1);
  myList0.addToFront(2);
  myList0.addToBack(3);

  cout << myList0;

  LinkedList myList1;

  myList1.addToFront(1);
  myList1.addToFront(2);
  myList1.addToBack(3);

  cout << myList1;

  LinkedList myList2{"Other List"};

  myList2.addToFront(1);
  myList2.addToFront(2);
  myList2.addToBack(3);

  cout << myList2;

  const LinkedList emptyList("Empty List");
  emptyList.printList();
  
  return 0;
}

Further reading

There are other topics about classes that we didn’t cover here in these notes. They should be mostly things you have covered in previous courses, so it’s a matter of learning the C++ syntax. To name a few:

References