Inheritance, Polymorphism, Error Handling
Inheritance¶
One of the key pillars of object orientation is inheritance, which allows us to specialize an existing class for specific cases — extending or refining its existing properties and functions.
To explore how inheritance works and what possibilities the language offers, let’s first define a base class.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
As we can see, we created a Student class.
The neptun data member is private, meaning it can only be accessed within this class.
However, the name and enrolledHours members are not the usual private — they are marked as protected.
Protected members can also be accessed by derived classes, as we have seen before.
This means that name and enrolledHours can be directly modified in derived types, while neptun (the university identifier) remains a private property of each instance — inaccessible and unmodifiable from outside the class.
A PhD student is just like any other student, except that besides attending classes, they can also teach — that is, the original type is extended into a more specialized case.
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
With this, we have created a PhDStudent type derived from the Student class.
You can imagine this as placing a larger shell around the original object sphere:

Visibility of inheritance¶
1 | |
Inheritance is specified after a colon (:), but it is very important to define its visibility.
As shown here, the inheritance is public. This means that whatever the child class inherits from the parent, their visibility remains unchanged.
If the inheritance type is protected, then the parent’s originally public members will behave as if they were marked protected in the child class.
If we use private — or omit the specifier entirely — the inherited members will appear in the child class with private visibility.
Let’s illustrate this with a small example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | |
It is clear that the visibility of inheritance does not hide members from the derived class itself — instead, it changes the visibility of inherited members within that class.
Even if the inheritance is declared as private, the derived class can still access the inherited members that were not private in the base class.
What changes is that other classes will no longer be able to access those inherited members through this derived class or type.
Base Class Constructor¶
As shown in the figure above, the derived class contains the base class.
Therefore, if we want to fully initialize the derived class, we must also initialize the base class within it.
If there is no default initialization (i.e., no default constructor), we must specify how or which constructor should be used to initialize the base class.
We did this in the case of PhD_hallgato as follows:
1 2 3 | |
It can be seen that the first element in the initializer list is a call to a Student constructor.
This is how we specify what data should be passed to the base part of the object.
It is important that base class constructors are always called first — only after that can we initialize the derived class’s own data members!
Base Class Member Functions¶
We have seen how to initialize data belonging to the base class and that we cannot access the base class’s private members directly.
However, the information passed to the constructor is not lost, since if the base class provides methods (such as getters) that access private data, those methods can be used in the derived class as well — provided they are not private themselves.
In the following example, we demonstrate that a member function of PhD_student can call the inherited Student::attendClasses() method, which in turn works with the base class’s private data.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | |
Output
Now the PhD student went to attend a class
The student is attending a class: Meme Joe 5
In C++, it is possible for both the base class and the derived class to contain methods with the same signature.
In this example, the attendClasses method is such a case.
If we want to explicitly call the version of the method defined in the Student (base) class from within the PhD_student class, we must use the scope resolution operator ::.
In the main function, we create a PhD_student object and first call the attendClasses method defined in the PhD_student class.
This method, in turn, uses scope resolution to call the identically named method defined in the base class (Student).
Polymorphism¶
One of the key features of object-oriented programming languages is dynamic polymorphism — when, at a given point in the program, we reference an object through its base class type, yet the object is able to respond according to its actual (derived) type when methods are called on it.
In the case of Student and PhD_student, we can write functions that expect a Student, but receive a PhD_student.
Within these functions, we can only access the methods defined in the Student class through the parameter.
However, in some cases, we may want the overridden version of a method in the derived class to be called instead of the base version.
For overriding to occur, the following must match between the base and derived class methods:
- the method name
- the method parameters
- the method qualifiers (e.g.,
const)
In the example above, the method void attendClasses() const satisfies these conditions in both the Student and PhD_student classes.
Let’s see what happens if we write a function that takes a Student or a Student& parameter and calls its attendClasses method!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | |
Output
The student is attending a class: Meme Joe 5
The student is attending a class: Simple 6
Whichever case we choose — whether we pass a Hallgato or a Hallgato& to our global oratHallgat function — it will always call the method defined in the Hallgato class.
Thus, dynamic polymorphism has not been achieved this way.
To understand why, let’s take a closer look at what happens in each case!
Pass-by-Value Parameter¶
In this case, we explicitly define that the oratHallgat function expects a parameter of type Hallgato.
When we pass a PhD_hallgato object to it, the system creates a copy of that object — but this copy only includes the data that corresponds to the static type, i.e., Hallgato.

Pass-by-Reference¶
As we’ve seen many times before, when passing by reference, we are referring to the original object,
but we view it as an instance of its base class.
This means that while we can only access the base part of the object, the complete derived object still exists around it.

However, even if we pass a PhD_hallgato object to the oratHallgat function as a simple Hallgato reference,
the method that gets called is still the one defined in the base class (Hallgato).
The reason is that the type used in the function call is determined statically (i.e., at compile time), not at runtime.
To ensure that the method corresponding to the dynamic type (the actual type of the object) is called instead,
we must use virtualization.
Virtualization uses a so-called virtual table (vtable) that describes how each object should behave during polymorphic calls.
In C++, this virtual table mechanism is not enabled by default, as it requires extra resources.
However, we can specify which methods should use it by marking them with the virtual keyword
(it is sufficient to do so in the base class).
Virtualization¶
As we’ve seen, virtualization is not enabled by default!
Let’s look at a modified example where we make the orakatHallgat method virtual.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 | |
Output
Now the PhD student went to a lecture
Student attending class: Meme Joe 5
Student attending class: simple 6
When parameters are passed by reference, polymorphic calls are realized for methods marked with the virtual modifier.
However, when parameters are passed by value, the call remains bound to the static type, since the copied object “loses” the additional properties required for the overridden behavior.
Therefore, if we want to handle types polymorphically, we must do the following:
- Use the
virtualkeyword for the desired methods - Use references (or pointers) for parameter passing
Override¶
Because of the strict requirements of virtual overriding, even small typos can lead to not properly overriding the intended method.
To prevent this, we can mark the overriding method with the override specifier, which makes the compiler verify that we are indeed overriding the correct method prototype.
If we mark a method with this specifier but no corresponding virtual method exists in any base class in the inheritance chain, we will get a compilation error.
In the example, the orakatHallgat method of the PhD_hallgato class can be written most safely as follows (the virtual keyword can be omitted, but the override specifier indicates that there must be a corresponding virtual method in the base class):
1 2 3 4 | |
Pure Virtual Methods¶
In the previous sections, we explored the essence of virtualization. Now, we will extend this concept with the notion of pure virtual methods.
As we have seen, the behavior of virtualized methods can be polymorphically overridden. In all our earlier examples, the method was also implemented in the base class, and the derived class simply modified that behavior.
However, there are many cases where the desired behavior in the base class cannot be clearly defined—or not defined at all. In such cases, it makes little sense to implement those methods in the base class. Likewise, it would make little sense to instantiate the base class itself, because only the more specific derived classes will have the necessary data and behavior to provide a meaningful implementation.
We have already encountered this concept in Java, where such methods and classes were called abstract methods and abstract classes. Abstract classes cannot be instantiated. While Java has a dedicated abstract keyword for this purpose, in C++ a class becomes abstract if it contains at least one pure virtual method. This also means that such a class cannot be instantiated.
A pure virtual method is a method without an implementation in the class (it doesn’t just have an empty body—there is no definition at all). If we only declared such a method without indicating it is pure, we would get a compilation error because a declaration without a definition is incomplete.
To mark a method as pure virtual, we assign it a value of 0. For example:
1 2 3 4 5 6 7 | |
As seen, the performDuties method is virtual, but it lacks an implementation — this is indicated by writing = 0 instead of a function body.
In this case, every class lower in the inheritance chain (direct descendants) must implement this method.
If a derived class does not implement it, that class also becomes an abstract class.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | |
As you can see, Student is a university member and a type that can be instantiated, since it implements the pure virtual method.
In contrast, Employee is still a general entity (non-instantiable), as it could represent a research associate, someone working in administration, or many other roles. All employees attend meetings, but they may participate in different ways (polymorphic call → virtual method).
Since Employee is also abstract, it must have a pure virtual method — this is the inherited but NOT yet implemented performDuties pure virtual method.
1 2 3 4 5 6 7 | |
The Economist is a type we want to instantiate, so it must implement all pure virtual methods — in this case, the performDuties method.
Since only Economist and Student are instantiable types, objects can be created only from them, but all other types can still appear as base types or as parameters.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | |
As we can see, the Economist and Student types can be instantiated, while the other types can only be used as parameter or variable types.
We can create a variable of type Employee polymorphically, but we must ensure that an actual Economist object (for example) is assigned to it.
This can be achieved by using a pointer or a reference.
It is very important to note that an Economist created as a UniversityCitizen cannot be passed as an Employee,
since the system only knows that it’s dealing with a UniversityCitizen type (or one of its derived types).
The runtime type and its specific behavior are no longer known in that context.
Error Handling¶
During program execution, various types of errors may occur — some of which we can prepare our program to handle.
These may include system-related errors (e.g., running out of memory, missing permissions to access a resource) or user errors (e.g., entering text instead of a number when calling a conversion function).
Such errors must be handled; otherwise, the program may continue running with undefined behavior.
As an example, let’s look at a simple case of error handling!
Suppose we have a string that we want to convert into a number.
If the conversion fails, the program’s execution stops, and an exception is thrown.
This exception can be of any data type, which we must catch and handle appropriately.
Such exceptions can be handled using a try–catch pair.
Every error that occurs within the try block can be handled by one or more corresponding catch blocks.
For example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
Output
Error during conversion: stoi
It can be seen that in the try block we attempt to execute a task, but an error occurs. In the catch block, however, several details should be highlighted:
constparameter: Thecatchblock receives an object, which can be constant. Since we typically do not want to modify the error description ourselves, adding theconstkeyword ensures we can catch it more reliably.- Reference parameter: Error types are often defined by some class. As we’ve seen, using references is useful for polymorphic handling — errors should also be caught by reference for this reason.
whatmethod: Built-in exception classes have a specific method calledwhat, which describes the nature of the error.
std::exception¶
Most of the time, we create separate classes for different types of errors. These are derived from the std::exception class. The purpose of this class is to provide a polymorphic base for error handling, and it defines the virtual what method.
Since types can be used polymorphically, the previous example could have also caught the error using the std::exception type:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
Output
Error during conversion (using exception type):: stoi
As you can see, we still get the 'stoi' message, because the std::invalid_argument error is thrown — we just handled it polymorphically.
Without using a reference, however, we would only get the std::exception message, because polymorphism does not work with pass-by-value.
INCORRECT USAGE:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
Output
Error occurred during conversion (using exception type): std::exception
It is important to note that this is not the best way to catch an error. The compiler will usually give a warning, which is worth paying attention to, as it can help identify, diagnose, and fix the error more accurately. Depending on the compiler, you might see a message like this:
warning: catching polymorphic type ‘const class std::exception’ by value [-Wcatch-value=] catch(const std::exception error) {
Multiple catch blocks¶
You can write multiple catch conditions for a try block, since different types of errors can occur during a single operation. These conditions are tested in order, and it is important to remember, as we learned earlier, that the first matching catch block will handle the error (not necessarily the most specific one, so if an error of a child type occurs but the catch block for the parent appears earlier in the code, the parent’s handler will always run).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | |
Ouptut
Error during conversion (second catch block for out_of_range): stoi
It is visible that the program continues execution in the second catch block because the std::invalid_argument exception cannot polymorphically handle the currently thrown std::out_of_range exception. The error then moves to the next catch block, which can handle it, so it is handled there.
In the example, we also prepared to handle other errors, not just the two conversion errors. Since std::exception is the base class for a large group of exceptions, it can indeed handle many types of errors.
For this reason, if we place this catch block in the wrong order, i.e., before all the specific ones, we lose information. If written first, every error would be caught polymorphically by std::exception, so the control would never reach the specific catch blocks.
BAD EXAMPLE:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | |
Output
General exception handling: stoi
Of course, the compiler also warns us about this:
warning: by earlier handler for ‘std::exception’ catch(const exception& error) { // BAD! Catches all errors polymorphically
Custom Error Type¶
We can also define our own error type and make it similarly general to the errors we have seen so far, by deriving it directly or indirectly from the std::exception class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | |
Output
This is a simple custom error!
This is my custom error. Ready to use
Division by zero
The error was caused by the following number: -34
The SimpleCustomError type only overrides the built-in what method so that the message displayed when the error is printed is the one we specify (this could also be done by instantiating an exception, but this way we have our own custom error type and can write a custom error handler for our defined type). Since CustomError can also be used as a general std::exception, its usage is all that remains. It's not enough to just construct it; it must also be thrown. In addition to what we’ve seen so far, error objects are just like any other object—they can have member variables, different constructors, and methods (as we saw with the getters).
noexcept keyword¶
When overriding the what method, we had to specify the noexcept property. This indicates that the method is guaranteed not to throw an exception during its execution.
throw keyword¶
If a program encounters an error during execution, the programmer must ensure that an exception is thrown. This can be done using the throw keyword. You must specify what you want to throw. This can be, for example, an instance of CustomError.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | |
Output
Something went wrong during the execution of foo.
Exception Handling Continued...¶
Non-class type errors¶
At the beginning of the error handling discussion, it was mentioned that errors are usually class types; however, in C++ this is not strictly required (in Java it had to be this way, and it also mattered which class the exception object derived from). In C++, anything can be thrown—any value; be it const char*, std::string, int, etc. Of course, these can also be caught by reference, but for primitive types this is not necessary.
What is important, however, is that if the type of the thrown exception does not derive from std::exception, then naturally it cannot be caught in a branch expecting an std::exception.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
Output
The error: 42
This is a const char* literal
Catching All Exceptions¶
Since some errors do not fall under std::exception, they cannot be handled generally. However, if we want to ensure that we catch every error, we would need to write a catch block for every possible error type. This is not always feasible (either because there are many different types of errors we want to handle uniformly, or because we do not know exactly which errors the program should be prepared for). However, we can handle this by using ... (three dots) in the catch clause. This allows us to catch anything in the catch block.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | |
Output
Unknown error 1
Unknown error 2
Final classes and methods¶
In Java, it is possible to use the final keyword to ensure that a method cannot be overridden in a derived class, or to indicate that a class cannot be inherited from.
The C++11 standard also provides this possibility in C++, although in C++ final is not a keyword by itself. This means that in the appropriate context (after a method or class name) it serves the same purpose as in Java; in all other cases, it is just treated as a normal identifier.
Multiple inheritance¶
In C++, it is possible for a class to inherit from multiple other classes. The main "issue" in this case is handling name conflicts, i.e., when the class inherits elements with the same signature from multiple sources.
If the doubly inherited members are not virtual, we can simply refer to them in the derived class using their scoped names, whether they are data members or methods.
For virtual methods, the virtual table is inherited from the base classes. If we do not want to call a method directly on the derived type but only through a base class, it is clear which table should be used for resolving the call.
However, if we want to call a doubly inherited method through an object of a multiply derived class, this is only possible if the method has been overridden in the derived class.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | |
Output
ClassA non-virtual method - value: 5
ClassB non-virtual method - value: 1
ClassC virtual method - ClassA::value: 5; ClassB::value: 1
ClassC virtual method - ClassA::value: 5; ClassB::value: 1
ClassC virtual method - ClassA::value: 5; ClassB::value: 1
Created: 2025-11-27