Object Oriented Programming
Contents
Introduction
The motivation behind object oriented programming (OOP) is to provide three things:
- encapsulation (abstraction)
- inheritance
- polymorphism
For the time being, we will focus on the first one, encapsulation. Encapsulation provides a layer of abstraction for data and operations on that data. Through structs and another construct called a class (both of these are more generally called objects, hence the term OOP), we are able to expose methods and hide the implementation. We'll talk about the logistics of encapsulation in much of this document. Inheritance (referring to organizing classes in a hierarchy) and polymorphism (referring to the generalization of data types) will not be covered in this course.
(Back to top)Encapsulation
Think back to why C++ structs are useful: they help us keep track of several pieces of related data. For instance, in the Notes programming assignments, we use structs to keep track of each note's content, priority level, and a flag indicating if it has been deleted. Without using an object like a struct, we would have to store three variables per note, which could get quite unwieldy. Here's an example of our Note struct:
(Back to top)Data members and instance methods
Within an object, a more formal name for fields (like content
, priorityLevel
, and isDeleted
above) is data members. Data members can be any kind of variable or constant—ints, strings, floats, arrays, other object instances, etc. We can also include functions, which are called instance functions or instance methods (I will generally use the term "method" to talk about instance functions, and "function" to talk about regular functions). For example, we can add a function called print()
to the Note struct, which will print out a note's content when invoked:
Notice that we use the variable content
and priorityLevel
within the method. They were never declared in the method, though! Within an instance method, we have access to all of the instance's data members. So because content
is not declared within print()
, we next check the scope of the data members. It's there, so we use that. If content
was not a data member, then we would get a compiler error. Something else to understand: the print()
method is working with a specific instance of Note—it does not have access to other instances of Note. Here's how we'd use it:
Every object has a special data member called this
, which is a pointer to the instance itself. If for whatever reason you find that you have a locally defined variable with the same name as a data member that you need to access, you can access the data member via this
. Here's our print()
method using the this
data member:
We use the arrow syntax (e.g., this->content
) because this
is a pointer and we have to dereference it.
Constructors
We're always looking for ways to code less. In the example above, we have to initialize each of the fields of our note, note.content
, note.priorityLevel
, and note.isDeleted
. If we have 10 instances, we have to initialize each of them, resulting in 30 lines of code (yuck!). Luckily, objects in C++ (and indeed, in most OOP languages) allow us to define a special method called a constructor. As its name implies, it constructs an instances of an object for us. It is invoked when you create an instance. Like any other function or method, it can take parameters. Unlike other functions and methods, constructors must not have a return type specified, and should be named after the object. For our Note struct, the constructor must be called Note as well. Here's what it looks like:
This constructor takes one parameter per data member. It then sets the data members to the corresponding values passed in. If we have this constructor, then we must initialize our Note instances like this:
Ha! Look at that, we saved three lines of code! Now, because our constructor says it takes three parameters, we must create instances by passing in all three parameters. So our old code won't work! But we can make it work if we add another constructor. That's right, we can have multiple constructors. The only catch is that they must each have a unique signature. Here's another version of Note that has two constructors, one that takes no parameters, and another that takes three:
We call a constructor with no parameters the default constructor. One other thing about constructors: there is an easier way to set data members. We can use something called a data member initializer list. This can only be used with constructor. It looks like this:
The syntax is, after the constructor signature but before the opening curly brace, put a colon followed by pairs of dataMember(parameter)
, each separated by a comma. So content(content)
will set the data member this->content
to the value of the parameter content
. The parameter names can be anything you want, as long as they match the parameters in the signature of the constructor. That is, they do not have to match the data member name. Another valid version (though with poor variable names) of the above constructor is:
Access levels
So far, any data members or methods we've stuck in a struct definition have been accessible on the instance from outside the object. That is, in main()
, we can do:
Sometimes it's handy to hide some data members and methods from the "outside" world (outside the object, that is, e.g., from main). There are three levels of access we can use: public, private, and protected. We will only concentrate on the first two for now. By default, every data member and method in a struct is considered public unless otherwise noted. We can make it explicit by putting the public:
keyword above the first data member:
To make a data member or method available from any other methods within an object, but not from outside, we can use the private:
keyword. In this next example, I've created a new private data member called timesModified
:
To motivate why private fields are nice, think about this. Suppose we want to use timeModified
to keep track of the number of times our note's content has been changed or deleted. In order to track this, we need know when a note has been changed. If the content
, priorityLevel
, and isDeleted
members are public, then any code with access to an instance of a note can change those three fields, and we have no way of monitoring the changes.
One way can can work around this is to make those three fields private, then create getters and setters. These are methods that will get and set the value of the private data members. Inside of these methods, we can track the times modified. Here's an example:
So now, we cannot do note.content = "Hello!";
. Instead, we have do note.setNoteFeilds("Hello!", 1, false);
. To get a field, we have to use one of the getters: cout << note.getContent() << endl;
. We can get the number of times the the note has been modified by using note.getTimesModified()
.
Classes
Structs are not the only construct in C++ for representing an object. Another construct is a class. Strangely, structs and classes are actually the same thing in C++, with the one exception that the default access level for structs is public, while it is private for classes. They can both have public and private data members and methods. They can have constructors, too. In practice, structs are usually used as a container for several publicly accessible data members (this type of thing is commonly called a Plain Old Data (POD) stucture). So, if you are including methods, you should probably be using a class instead of a struct. In other words, in our complicated examples from the previous sections, we would normally use a class rather than a struct.
We can implement a class almost identically to a struct; we just need to use the class
keyword instead of struct
and we need to make data members publicly accessible:
While we are allowed to define classes above main just like structs, it is not best practice. Instead, we should create two separate files: the header file which consists of the class specification or class interface (the file name should end in .h
) and a file for the class implementation (the file name should end in .cpp
). Class names conventionally start with a capital letter, so we would use Person
instead of person
. In addition, it is typical that data members are not exposed publicly, but instead methods (functions defined within the class) are used to get or set data member values. Here's an example:
To compile, we need to include both the .cpp file with main()
, as well as the .cpp for any classes we're using. Here's how we compile the example from above:
$ g++ Person.cpp main.cpp -o person $ ./person Name: Bobby Age: 8 Gender: m
Now, a couple of important points about the code above. First, in Person.cpp and main.cpp, we have a line near the top that says #include "Person.h"
. This loads our class interface. Think of this as copying and pasting the contents of Person.h right where #include "Person.h"
. It's important to use quotes here and not angled brackets! Angled brackets are for built-in libraries.
Now consider the #ifndef PERSON_H
, #define PERSON_H
, and #endif
lines in Person.h. These are pre-compiler directives, just like #include ...
, which means they are analyzed before compilation occurs. The first one says to only compile the code between it and the corresponding #endif
statement if the tag PERSON_H
has not been defined using the #define
pre-compiler statement. The second line defines the tag. Collectively, these allow the class interface to appear exactly once in the compiled code. This is important, because both main.cpp and Person.cpp include the Person.h file. If we did not include the #ifndef
/#define
/#endif
statements, then we would wind up with two definitions of our class and a compiler error.
Another strange thing is the Person::
placed in front of every instance method in Person.cpp. These let the compiler know that the method definition belongs to the Person class. Every instance method should have the class name and two colons immediately proceed its name, but after the return type.
Passing instances to functions
Beware that struct and class instances can be passed to functions, but if they are not passed by reference, then none of the changes to any non-pointer data members will not be reflected outside of the function. This is because the entire instance is copied in pass by reference, data members included.
(Back to top)