COMP6771 Week 3.1
Object-Oriented Programming
Scope
- The scope of a variable is the part of the program where it is accessible
- Scope starts at variable definition
- Scope (usually) ends at next "}"
- You're probably familiar with this even if you've never seen the term
- Define variables as close to first usage as possible
- This is the opposite of what you were taught in first year undergrad
- Defining all variables at the top is especially bad in C++
Object Lifetimes
- An object is a piece of memory of a specific type that holds some data
- All variables are objects
- Unlike many other languages, this does not add overhead
- Object lifetime starts when it comes in scope
- "Constructs" the object
- Each type has 1 or more constructor that says how to construct it
- Object lifetime ends when it goes out of scope
- "Destructs" the object
- Each type has a different "destructor" which tells the compiler how to destroy it
Construction
- Eg. https://en.cppreference.com/w/cpp/container/vector/vector
- Generally use () to call functions, and {} to construct objects
-
- () can only be used for functions, and {} can be used for either
- There are some rare occasions these are different
- Sometimes it is ambiguous between a constructor and an initialize list
int main() {
std::vector<int> v11; // Calls 0-argument constructor. Creates empty vector.
// There's no difference between these:
// T variable = T{arg1, arg2, ...}
// T variable{arg1, arg2, ...}
std::vector<int> v12{}; // No different to first
std::vector<int> v13 = std::vector<int>(); // No different to the first
std::vector<int> v14 = std::vector<int>{}; // No different to the first
std::vector<int> v3{v2.begin(), v2.end()}; // constructed with an iterator
std::vector<int> v4{v3}; // Constructed off another vector
std::vector<int> v51{5, 2}; // Initialiser-list constructor {5, 2}
std::vector<int> v52(5, 2); // Count + value constructor (5 * 2 => {2, 2, 2, 2, 2})
}
Construction
- Also works for your basic types
-
But the default constructor has to be manually called
- This potential bug can be hard to detect due to how function stacks work (variable may happen to be 0)
- Can be especially problematic with pointers
-
But the default constructor has to be manually called
int main() {
int n; // not constructed (memory contains previous value)
int n2{}; // Default constructor (memory contains 0)
int n3{5};
// This version is nice because it gives us an error.
int n4{5.5};
// You need to explictly tell it you want this.
int n6{static_cast<int>(5.5)};
// Not so nice. No error
int n5 = 5.5;
}
Why are object lifetimes useful?
Can you think of a thing where you always have to remember to do something when you're done?
void ReadWords(const std::string& filename) {
std::ifstream f{filename};
std::vector<std::string> words;
std::copy(std::istream_iterator<std::string>{f}, {}, std::back_inserter{words});
f.close();
}
- What happens if we omit f.close() here (assume similar behavior to c/java/python)?
- How easy to spot is the mistake
- How easy would it be for a compiler to spot this mistake for us?
- How would it know where to put the f.close()?
RAII
-
Resource acquisition is initialisation
-
A concept where we encapsulate resources inside objects
- Acquire the resource in the constructor
- Release the resource in the destructor
-
eg. Memory, locks, files
-
Every resource should be owned by either:
-
Another resource (eg. smart pointer, data member)
-
The stack
-
A nameless temporary variable
-
Noexcept
- Exceptions will be covered in week 5, but the short version is that they are recoverable, but critical errors
- A noexcept-specified function tells the compiler not to generate recovery code
- An exception thrown in a noexcept function will terminate your program
- Use noexcept to guarantee that callers needn't worry about exception-handling.
- You can use noexcept to say that you don't mind your whole program ending if something goes wrong in this function.
Destructors
- Call when the object goes out of scope
- What might this be handy for?
- Does not occur for reference objects (why?)
- Marked noexcept (why?)
- Why might destructors be handy?
Destructors
- Called when the object goes out of scope
- What might this be handy for?
- Does not occur for reference objects (why?)
- Marked noexcept (why?)
- Why might destructors be handy?
- Freeing pointers
- Closing files
- Unlocking mutexes (from multithreading)
- Aborting database transactions
class MyClass {
~MyClass() noexcept;
};
MyClass::~MyClass() noexcept {
// Definition here
}
What is OOP
- A class uses data abstraction and encapsulation to define an abstract data type:
- Interface: the operations used by the user (an API)
- Implementation: the data members the bodies of the functions in the interface and any other functions not intended for general use
- Abstraction: separation of interface from implementation
- Encapsulation: enforcement of this via information hiding
- Example: Bookstore - bookstore.h (interface), bookstore.cpp (implementation), user code (knows the interface).
C++ classes
- A class:
- Defines a new type
- Is created using the keywords class or struct
- May define some members (functions, data)
- Contains zero or more public and private sections
- Is instantiated through a constructor
- A member function:
- must be declared inside the class
- may be defined inside the class (it is then inline by default)
- may be declared const, when it doesn’t modify the data members
- The data members should be private, representing the state of an object.
Abstraction and encapsulation
- Abstraction is separating the interface from the implementation
- Encapsulation is hiding details about class representation and implementation
- An object’s state can only be accessed/modified via the public interface
Advantages:
- Object state is protected from user-level errors
- Users can't break invariants by changing something
- Class implementation may evolve over time
- If you change a variable or a private function, users don't need to change anything
Incomplete types
- An incomplete type may only be used to define pointers and references, and in function declarations (but not definitions)
- Because of the restriction on incomplete types, a class cannot have data members of its own type.
- But the following is legal, since a class is considered declared once its class name has been seen:
struct Node {
int data;
// Node is incomplete - this is invalid
// This would also make no sense. What is sizeof(Node)
Node next;
};
struct Node {
int data;
Node* next;
};
Member access control
- This is how we support encapsulation and information hiding in C++
class Foo {
public:
// Members accessible by everyone
Foo();
protected:
// Members accessible by members, friends, and subclasses
// Will discuss this when we do advanced OOP in future weeks.
private:
// Accessible only by members and friends
void PrivateMemberFunction();
int private_data_member_;
public:
// May define multiple sections of the same name
};
Classes and structs in C++
- A class and a struct in C++ are almost exactly the same
- The only difference is that:
- All members of a struct are public by default
- All members of a class are private by default
- People have all sorts of funny ideas about this. This is the only difference
- We use structs only when we want a simple type with little or no methods and direct access to the data members (as a matter of style)
- This is a semantic difference, not a technical one
- A std::pair or std::tuple may be what you want, though
Friends
- A class may declare friend functions or classes
- Those functions / classes are non-member functions that may access private parts of the class
- This is, in general, a bad idea, but there are a few cases where it may be required
- Nonmember operator overloads (will be discussing soon)
- Related classes
- A Window class might have WindowManager as a friend
- A TreeNode class might have a Tree as a friend
- Container could have iterator_t<Container> as a friend
- Though a nested class may be more appropriate
- Use friends when:
- The data should not be available to everyone
- There is a piece of code very related to this particular class
Class Scope
- Anything declared inside the class needs to be accessed through the scope of the class
- Scopes are accessed using "::" in C++
// foo.h
class Foo {
public:
// Equiv to typedef int Age
using Age = int;
Foo();
Foo(std::istream& is);
~Foo();
void MemberFunction();
};
// foo.cpp
#include "foo.h"
Foo::Foo() {
}
Foo::Foo(std::istream& is) {
}
Foo::~Foo() {
}
void Foo::MemberFunction() {
Foo::Age age;
}
This pointer
- A member function has an extra implicit parameter, named this
- This is a pointer to the object on behalf of which the function is called
- A member function does not explicitly define it, but may explicitly use it
- The compiler treats an unqualified reference to a class member as being made through the this pointer.
- The this pointer always has top-level const
- For the next few slides, we'll be taking a look at the BookSale example in the course repo
This pointer
- A member function has an extra implicit parameter, named this
- This is a pointer to the object on behalf of which the function is called
- A member function does not explicitly define it, but may explicitly use it
- The compiler treats an unqualified reference to a class member as being made through the this pointer.
- The this pointer always has top-level const
- For the next few slides, we'll be taking a look at the BookSale example in the course repo
Const objects
- Member functions are by default only be possible on non-const objects
- You can declare a const member function which is valid on const objects
- A const member function may only modify mutable members
- A mutable member should mean that the state of the member can change without the state of the object changing
- Good uses of mutable members are rare
- Mutable is not something you should set lightly
- One example where it might be useful is a cache
- Let's make the BookSale class const correct
Are the following correct
Are the following correct?
Sales_data a{"Harry Potter"};
Sales_data b{"Harry Potter"};
a.combine(b).print(std::cout);
a.print(std::cout).combine(b);
Are the following correct
Are the following correct?
- The combine/print is fine
- The print/combine fails since print returns a const reference through which we cannot call a nonconst member
- Four possible ways to get it to compile. Discuss.
- Make combine a const function
- Make print a non-const function
- Add an overload to print for non-const
- Change the user code
Sales_data a{"Harry Potter"};
Sales_data b{"Harry Potter"};
a.combine(b).print(std::cout);
a.print(std::cout).combine(b);
Constructors
- Constructors define how class data members are initalised
- A constructor has the same name as the class and no return type
- Default initalisation is handled through the default constructor
- Unless we define our own constructors the compile will declare a default constructor
- This is known as the synthesized default constructor
for each data member in declaration order
if it has an in-class initialiser
Initialise it using the in-class initialiser
else if it is of a built-in type (numeric, pointer, bool, char, etc.)
do nothing (leave it as whatever was in memory before)
else
Initialise it using its default constructor
The synthesized default constructor
- Is generated for a class only if it declares no constructors
- For each member, calls the in-class initialiser if present
- Otherwise calls the default constructor (except for trivial types like int)
- Cannot be generated when any data members are missing both in-class initialisers and default constructors
class C {
int i{0}; // in-class initialiser
int j; // Untouched memory
A a;
// This stops default constructor
// from being synthesized.
B b;
};
class A {
int a_;
};
class B {
B(int b): b_{b} {}
int b_;
};
Constructor initialiser list
- The initialisation phase occurs before the body of the constructor is executed, regardless of whether the initialiser list is supplied
- A constructor will:
- Construct all data members in order of member declaration (using the same rules as those used to initialise variables)
- Execute the body of constructor: the code may assign values to the data members to override the initial values
Constructor initialiser list
class NoDefault {
NoDefault(int i);
}
class B {
// Constructs s_ with value "Hello world"
B(int& i): s_{"Hello world"}, const_{5}, no_default{i}, ref_{i} {}
// Doesn't work - constructed in order of member declaration.
B(int& i): s_{"Hello world"}, const_{5}, ref_{i}, no_default{ref_} {}
B(int& i) {
// Constructs s_ with an empty string, then reassigns it to "Hello world"
// Extra work done (but may be optimised out).
s_ = "Hello world";
// Fails to compile
const_string_ = "Goodbye world";
ref_ = i;
// This is fine, but it can't construct it initially.
no_default_ = NoDefault{1};
}
std::string s_;
// All of these will break compilation if you attempt to put them in the body.
const int const_;
NoDefault no_default_;
int& ref_;
};
Delegating constructors
- A constructor may call another constructor inside the initialiser list
- Since the other constructor must construct all the data members, do not specify anything else in the constructor initialiser list
- The other constructor is called completely before this one.
- This is one of the few good uses for default values in C++
- Default values may be used instead of overloading and delegating constructors
Static members
- Both data and function members may be declared static
- These are essentially globals defined inside the scope of the class
- Use static members when something is associated with a class, but not a particular instance
- Static data has global lifetime (program start to program end)
// For use with a database
class User {
static std::string table_name;
static std::optional<User> query(const std::string& username);
void commit();
std::string username;
}
User user = *User::query("Alice");
user.username = "Bob"
User::commit(); // fails to compile (commit is not static)
user.commit();
std::cout << User::table_name;
std::cout << User::username; // Fails to compile
Explicit type conversions
- If a constructor for a class has 1 parameter, the compiler will create an implicit type conversion from the parameter to the class
- This may be the behaviour you want
class Age {
Age(int age);
};
// Explicitly calling the constructor
Age age{20};
// Attempts to use an integer
// where an age is expected.
// Implicit conversion done.
// This seems reasonable.
Age age = 20;
class IntVec {
// This one allows the implicit conversion
IntVec(int length): vec_(length, 0);
This one disallows it.
explicit IntVec(int length): vec_(length, 0);
std::vector<int> vec_;
};
// Explictly calling the constructor.
IntVec container{20};
// Implicit conversion.
// Probably not what we want.
IntVec container = 20;
OOP design
- There are several special functions that we must consider when designing classes
- For each of these functions, ask yourself:
- Is it sane to be able to do this?
- Does it have a well-defined, obvious implementation
- If the answer to either of these is no, write "<function declaration> = delete;"
- Then ask yourself "is this the behaviour of the compiler-synthesized one"
- If so, write "<function declaration> = default;"
- If not, write your own definition
- Let's discuss these questions for these types over the next few slides:
- std::vector
- Mutex
- Pointer
Copying constructor
- Constructs one object to be a copy of another
- The compiler-generated copy-constructor just calls each member's copy constructor in order of declaration
class T {
T(const T&);
};
Copying assignment
- Like a copy constructor, but the destination is already constructed
- Requires destroying the old data, and constructing the new data
- Copy-and-swap idiom is an elegant way of doing this
- It constructs then destructs. Since construction might fail, it should go first
- Requires move assignment to be defined
- Takes in an lvalue
- Compiler-generated one performs memberwise copy-assignment operator
class T {
// A copy-assignment operator
T& operator=(const T& original);
// The copy-and-swap idiom
// This is also a copy-assignment operator
T& operator=(T copy) {
std::swap(*this, copy);
return *this;
}
};
MyClass base;
MyClass copy_constructed = base;
MyClass copy_assigned;
copy_assigned = base;
Rvalue references
- Rvalue references look like T&& (lvalue is T&)
- An lvalue denotes an object whose resource cannot be reused
- Most objects (eg. variable, variable[0])
- Once the lvalue reference goes out of scope, it may still be needed
- An rvalue denotes an object whose resources can be reused
- eg. Temporaries (MyClass object in f(MyClass{}))
- When someone passes it to you, they don't care about it once you're done with it
- “The object that x binds to is YOURS. Do whatever you like with it, no one will care anyway”
- Like giving a copy to f… but without making a copy.
void f(MyClass&& x);
Rvalue references
void inner(int&& value) {
++value;
std::cout << value << '\n';
}
void outer(int&& value) {
inner(value); // This fails? Why?
std::cout << value << '\n';
}
int main() {
f1(1); // This works fine.
int i;
f2(i); // This fails because i is an lvalue.
}
- An rvalue reference formal parameter means that the value was disposable from the caller of the function
- If outer modified value, who would notice / care?
- The caller (main) has promised that it won't be used anymore
- If inner modified value, who would notice / care?
- The caller (outer) has never made such a promise.
- An rvalue reference parameter is an lvalue inside the function
- If outer modified value, who would notice / care?
std::move
// Looks something like this.
T&& move(T& value) {
return static_cast<T&&>(value);
}
- Simply converts it to an rvalue
- This says "I don't care about this anymore"
- All this does is allow the compiler to use rvalue reference overloads
void inner(int&& value) {
++value;
std::cout << value << '\n';
}
void outer(int&& value) {
inner(std::move(value));
// Value is now in a valid but unspecified state.
// Although this isn't a compiler error, this is bad code.
// Don't access variables that were moved from, except to reconstruct them.
std::cout << value << '\n';
}
int main() {
f1(1); // This works fine.
int i;
f2(std::move(i));
}
Move constructor
- Always should be declared noexcept
- Unless otherwise specified, objects that have been moved from are in a valid but unspecified state
- Will likely be faster than the copy constructor
- Compiler-generated one performs memberwise move-construction
class T {
T(T&&) noexcept;
};
Move assignment
- Always should be declared noexcept
- Like the move constructor, but the destination is already constructed
- Compiler-generated one performs memberwise move-assignment
class T {
T& operator=(T&&) noexcept;
};
Object lifetimes
To create safe object lifetimes in C++, we always attach the lifetime of one object to that of something else
- A variable in a function is tied to its scope
- A data member is tied to the lifetime of the class instance
- An element in a std::vector is tied to the lifetime of the vector
- A heap object should be tied to the lifetime of whatever object created it
- Examples of bad programming practice
- An owning raw pointer is tied to nothing
- A C-style array is tied to nothing
- Strongly recommend watching the first 44 minutes of Herb Sutter's cppcon talk "Leak freedom in C++... By Default"
Constructing wrapper types
class MyClass {
MyClass();
MyClass(int);
MyClass(const MyClass&);
MyClass(MyClass&&);
int GetValue();
};
// calls default constructor
std::optional<MyClass> opt1 = std::make_optional<MyClass>();
// calls int constructor
std::optional<MyClass> opt2 = std::make_optional<MyClass>(5);
// calls copy constructor
std::optional<MyClass> opt3 = *opt1;
// calls move constructor
std::optional<MyClass> opt4 = std::move(*opt1);
opt4->GetValue();
// Similar for make_unique and make_shared, but have to manually move / copy values
std::shared_ptr<MyClass> sp1 = std::make_shared<MyClass>();
std::shared_ptr<MyClass> sp2 = sp1;
std::shared_ptr<MyClass> sp3 = std::move(sp1);
MyClass* p2 = sp1.get();
std::unique_ptr<MyClass> up1 = std::make_unique<MyClass>(*sp1);
std::unique_ptr<MyClass> up2 = *up1; // But have to manually move / copy values like above
std::unique_ptr<MyClass> up3 = up1; // Fails (no copy constructor)
std::unique_ptr<MyClass> up4 = std::move(up1);
MyClass* p1 = up1.get();
up1->GetValue();
Namespaces
- A namespace encapsulates a set of functions, classes, and other namespaces
- If I have a really big piece of code, with several external pieces of code, what are the chances of two functions / classes having the same name somewhere in the code?
- You've already seen one namespace used a lot, without knowing it (std)
// path/to/file.h
namespace path {
namespace to {
class MyClass {
}
void MyFn();
} // namespace to
} // namespace path
// main.cpp
#include "path/to/file.h"
int main() {
path::to::MyClass myClass;
path::to::MyFn();
}
// path/to/file.cpp
namespace path {
namespace to {
MyFn() {
}
} // namespace to
} // namespace path
The using keyword
- Several ways to use the using keyword
- These are the two most important ways
// Far cleaner than typedef, and the
// arguments are the right way around!
int main() {
using intvec = std::vector<int>;
intvec vec;
C::iterator_t it;
}
class IteratorC {
}
class C {
using iterator_t = IteratorC;
}
#include "path/to/file.h"
int main() {
// Imports a single thing
// into the current scope.
using path::to::MyClass;
MyClass myClass;
}
Using declaration
Type alias
ADL
- Due to a feature of C++ called "Argument dependent lookup", use the using declaration for some specific functions
- This is a complex topic to be discussed in later weeks
- This is only relevant when using non-standard types
- Use this for the following functions
- std::swap
- std::[cr]begin
- std::[cr]end
- std::empty
- std::size
- std::data
#include "myclass.h"
using std::begin;
using std::end;
using std::swap;
int main() {
std::vector<MyClass> vec{{}, {}};
swap(vec[0], vec[1]);
for (auto it = begin(vec); it != end(vec); ++it) {
std::cout << *it << '\n';
}
}
Argument dependent lookup
- When looking up an unqualified function, the compiler first looks at the namespace of the type of the arguments
- If it contains a matching function declaration, it uses that
- Otherwise, it falls back to the normal function lookup
- Use ADL for std::swap, std::begin, and std::end
- You may write "using std::swap;"
- Do not write "using namespace std;"
// myclass.h
namespace ns {
void MyClass {
}
void swap(MyClass&, MyClass&);
}
#include "myclass.h"
int main() {
ns::MyClass v1, v2;
std::swap(v1, v2); // Calls std::swap
{
// Import std::swap to the current scope
using std::swap;
int i, j;
swap(i, j); // calls std::swap
swap(v1, v2); // calls ns::swap
}
}
COMP6771 19T2 - 3.1 - Basic OOP
By cs6771
COMP6771 19T2 - 3.1 - Basic OOP
- 1,072