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++ struct
s can also combine data with methods, struct
s 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 namespace
s 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 nullptr
s.
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:
#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
#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
#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:
- Inheritance: https://cplusplus.com/doc/tutorial/inheritance/
- Abstract classes: https://www.ibm.com/docs/en/zos/2.4.0?topic=only-abstract-classes-c
- Virtual functions: https://www.ibm.com/docs/en/zos/2.4.0?topic=only-virtual-functions-c
- Static members and methods: https://www.ibm.com/docs/en/zos/2.4.0?topic=only-static-member-functions-c