COMP6771

Advanced C++ Programming

Week 3.1

Class Types

In this lecture

Why?

  • The rules around scope and class/object types are fundamental to understanding how your C++ code works.

What?

  • Scope
  • Class types
  • switch

 

  • 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++

Scope

#include <iostream>

int i = 1;
int main() {
	std::cout << i << "\n";
	if (i > 0) {
		int i = 2;
		std::cout << i << "\n";
		{
			int i = 3;
			std::cout << i << "\n";
		}
		std::cout << i << "\n";
	}
	std::cout << i << "\n";
}

lecture-3/demo301-scope.cpp

  • Ways that we create scopes?
    • Classes
    • Namespaces
    • Functions
    • Global
    • Random braces

Scope

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

 

This is the behavior that primitive types follow, but you probably knew that intuitively. With classes, we tend to think a bit more explicitly about 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
auto main() -> int {
  // Always use auto on the left for this course, but you may see this elsewhere.
  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, ...}
  auto v12 = std::vector<int>{}; // No different to first
  auto v13 = std::vector<int>(); // No different to the first
  
  {
  	auto v2 = std::vector<int>{v11.begin(), v11.end()}; // A copy of v11.
  	auto v3 = std::vector<int>{v2}; // A copy of v2.
  } // v2 and v3 destructors are called here

  auto v41 = std::vector<int>{5, 2}; // Initialiser-list constructor {5, 2}
  auto v42 = std::vector<int>(5, 2); // Count + value constructor (5 * 2 => {2, 2, 2, 2, 2})
} // v11, v12, v13, v41, v42 destructors called here

lecture-3/demo302-construction.cpp

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
#include <iostream>

double f() {
	return 1.1;
}

int main() {
	// One of the reasons we do auto is to avoid ununitialized values.
	// int n; // Not initialized (memory contains previous value)

	int n21{}; // Default constructor (memory contains 0)
	auto n22 = int{}; // Default constructor (memory contains 0)
	auto n3{5};

	// Not obvious you know that f() is not an int, but the compiler lets it through.
	// int n43 = f();

	// Not obvious you know that f() is not an int, and the compiler won't let you (narrowing
	// conversion)
	// auto n41 = int{f()};

	// Good code. Clear you understand what you're doing.
	auto n42 = static_cast<int>(f());

	// std::cout << n << "\n";
	std::cout << n21 << "\n";
	std::cout << n22 << "\n";
	std::cout << n3 << "\n";
	std::cout << n42 << "\n";
}

lecture-3/demo303-construction2.cpp

Why are object lifetimes useful?

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();
}

Can you think of a thing where you always have to remember to do something when you're done?

  • 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()?

Namespaces

// lexicon.hpp
namespace lexicon {
    std::vector<std::string> read_lexicon(std::string const& path);

    void write_lexicon(std::vector<std::string> const&, std::string const& path);
} // namespace lexicon

Used to express that names belong together.

Prevent similar names from clashing.

// word_ladder.hpp
namespace word_ladder {
    std::unordered_set<std::string> read_lexicon(std::string const& path);
} // namespace word_ladder

Namespaces

// word_ladder.hpp
namespace word_ladder {
    std::unordered_set<std::string> read_lexicon(std::string const& path);
} // namespace word_ladder
// read_lexicon.cpp
namespace word_ladder {
    std::unordered_set<std::string> read_lexicon(std::string const& path) {
        // open file...
        // read file into std::unordered_set...
        // return std::unordered_set
    }
} // namespace word_ladder

Nested namespaces

namespace comp6771::word_ladder {
    std::vector<std::vector<std::string>>
    word_ladder(std::string const& from, std::string const& to);
} // namespace comp6771::word_ladder
namespace comp6771 {
    // ...
    
    namespace word_ladder {
        std::vector<std::vector<std::string>>
        word_ladder(std::string const& from, std::string const& to);
    } // namespace word_ladder
} // namespace comp6771

Prefer top-level and occasionally two-tier namespaces to multi-tier.

It's okay to own multiple namespaces per project, if they logically separate things.

Unnamed namespaces

In C you had static functions that made functions local to a file.

C++ uses "unnamed" namespaces to achieve the same effect.

Functions that you don't want in your public interface should be put into unnamed namespaces.

namespace word_ladder {
    namespace {
        bool valid_word(std::string const& word);
    } // namespace
} // namespace word_ladder

Unlike named namespaces, it's okay to nest unnamed namespaces.

Namespace aliases

namespace views = ranges::views;
namespace chrono = std::chrono;

Gives a namespace a new name. Often good for shortening nested namespaces.

Always fully qualify your function calls...

int main() {
    auto const x = 10.0;
    auto const x2 = std::pow(x, 2);
    
    auto const ladders = word_ladder::generate("at", "it");
    
    auto const x2_as_int = static_cost<int>(x2);
}

There are certain complex rules about how overload resolution works that will surprise you, so it's a best practice to always fully-qualify your function calls.

Using a namespace alias counts as "fully-qualified" only if the alias was fully qualified.

...even if you're in the same namespace

namespace word_ladder {
    namespace {
        bool valid_word(std::string const& word);
    } // namespace
    
    std::vector<std::vector<std::string>>
    generate(std::string const& from, std::string const& to) {
        // ...
        auto const result = word_ladder::valid_word(word);
        // ...
    }
} // namespace word_ladder

There are certain complex rules about how overload resolution works that will surprise you, so it's a best practice to always fully-qualify your function calls.

Using a namespace alias counts as "fully-qualified" only if the alias was fully qualified.

...even if you're in the same nested namespace

namespace word_ladder::something::very_long {
    namespace {
        bool valid_word(std::string const& word);
    } // namespace
    
    std::vector<std::vector<std::string>>
    generate(std::string const& from, std::string const& to) {
        // ...
        auto const result = word_ladder::something::very_long::valid_word(word);
        // ...
    }
} // namespace word_ladder

There are certain complex rules about how overload resolution works that will surprise you, so it's a best practice to always fully-qualify your function calls.

Using a namespace alias counts as "fully-qualified" only if the alias was fully qualified.

What is OOP (Object-oriented programming)

  • A class uses data abstraction and encapsulation to define an abstract data type:
    • Abstraction: separation of interface from implementation
      • Useful as class implementation can change over time
    • Encapsulation: enforcement of this via information hiding
  • This abstraction leads to two key parts of the 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

 

C++ classes

Since you've completed COMP2511 (or equivalent), C++ classes should be pretty straightforward and at a high level follow very similar principles.

  • 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.

Member access control

This is how we support encapsulation and information hiding in C++

class foo {
 public:
  // Members accessible by everyone
  foo(); // The default constructor.

 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 private_member_function();
  int private_data_member_;

 public:
  // May define multiple sections of the same name
};

Constructor

Constructors behave very similar to other programming languages

#include <iostream>

class myclass {
public:
    myclass(int i) {
        i_ = i;
    }
    getval() {
        return i_;
    }

private:
    int i_;
};

int main() {
    auto mc = myclass{1};
    std::cout << mc.getval() << "\n";
}

lecture-3/demo305-basic.cpp

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.
    • Generally we use a "_" suffix for class variables rather than a this-> to identify them
#include <iostream>

class myclass {
public:
    myclass(int i) {
        i_ = i;
    }
    int getval() {
        return i_;
    }

private:
    int i_;
};

int main() {
    auto mc = myclass{1};
    std::cout << mc.getval() << "\n";
}
#include <iostream>

class myclass {
public:
    myclass(int i) {
        this->i_ = i;
    }
    int getval() {
        return this->i_;
    }

private:
    int i_;
};

int main() {
    auto mc = myclass{1};
    std::cout << mc.getval() << "\n";
}

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 member_function();
};
// foo.cpp
#include "foo.h"

Foo::Foo() {
}

Foo::Foo(std::istream& is) {
}

Foo::~Foo() {
}

void Foo::member_function() {
  Foo::Age age;
  // Also valid, since we are inside the Foo scope.
  Age age;
}

A simple example

C++ classes behave how you expect

#include <iostream>
#include <string>

class person {
public:
	person(std::string const& name, int const age);
	auto get_name() -> std::string const&;
	auto get_age() -> int const&;

private:
	std::string name_;
	int age_;
};

person::person(std::string const& name, int const age) {
	name_ = name;
	age_ = age;
}

auto person::get_name() -> std::string const& {
	return name_;
}

auto person::get_age() -> int const& {
	return age_;
}

auto main() -> int {
	auto p = person{"Hayden", 99};
	std::cout << p.get_name() << "\n";
}

lecture-3/demo305-basic2.cpp

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
class foo {
 int member_; // default private
};
struct foo {
 int member_; // default public
};

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;
};

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:
    1. Construct all data members in order of member declaration (using the same rules as those used to initialise variables)
    2. Construct any undefined member variables that weren't defined in step (1)
    3. Execute the body of constructor: the code may assign values to the data members to override the initial values
#include <string>
#include <iostream>

class myclass {
public:
    myclass(int i) : i_{i} {}
    int getval() {
        return i_;
    }

private:
	int i_;
};

int main() {
    auto mc = myclass{5};
    std::cout << mc.getval() << "\n";
}

lecture-3/demo306-initlist.cpp

Constructor Logic

  • 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 used defined initialiser
    Initialise it using the used defined 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

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

Delegating constructors

#include <string>

class dummy {
public:
    explicit dummy(int const& i) : s_{"Hello world"}, val_{i} {
    }
    explicit dummy() : dummy(5) {
    }
    std::string const& get_s() {
        return s_;
    }
    int get_val() {
        return val_;
    }

private:
    std::string s_;
    const int val_;
};

auto main() -> int {
    dummy d1(5);
    dummy d2{};
}

lecture-3/demo307-deleg.cpp

Destructors

  • Called when the object goes out of scope
  • Why might destructors be handy?
    • Freeing pointers
    • Closing files
    • Unlocking mutexes (from multithreading)
    • Aborting database transactions
  • noexcept states no exception will be thrown (we will cover this later)
class MyClass {
  ~MyClass() noexcept;
};
MyClass::~MyClass() noexcept {
  // Definition here
}

declaration

definition

Explicit keyword

  • 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 (but usually not)
    • You have to opt-out of this implicit type conversion with the explicit keyword
class age {
public:
	age(int age)
	: age_{age} {}

private:
	int age_;
};

auto main() -> int {
	// Explicitly calling the constructor
	age a1{20};

	// Explicitly calling the constructor
	age a2 = age{20};

	// Attempts to use an integer
	// where an age is expected.
	// Implicit conversion done.
	// This seems reasonable.
	age a3 = 20;
}
#include <vector>

class intvec {
public:
	// This one allows the implicit conversion
	// intvec(std::vector<int>::size_type length)
	// : vec_(length, 0);

	// This one disallows it.
	explicit intvec(std::vector<int>::size_type length)
	: vec_(length, 0) {}

private:
	std::vector<int> vec_;
};

auto main() -> int {
	int const size = 20;
	// Explictly calling the constructor.
	intvec container1{size}; // Construction
	intvec container2 = intvec{size}; // Assignment

	// Implicit conversion.
	// Probably not what we want.
	// intvec container3 = size;
}

lecture-3/demo310-explicit1.cpp

lecture-3/demo310-explicit2.cpp

Const objects

  • Member functions are by default only callable by non-const objects
    • You can declare a const member function which is valid on const objects and non-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

Const member functions

#include <iostream>
#include <string>

class person {
 public:
    person(std::string const& name) : name_{name} {}
    auto set_name(std::string const& name) -> void {
    	name_ = name;
    }
    auto get_name() -> std::string const& {
    	return name_;
    }

 private:
    std::string name_;
};

auto main() -> int {
    person p1{"Hayden"};
    p1.set_name("Chris");
    std::cout << p1.get_name() << "\n";
    
    person const p1{"Hayden"};
    p1.set_name("Chris"); // WILL NOT WORK... WHY NOT?
    std::cout << p1.get_name() << "\n"; // WILL NOT WORK... WHY NOT?
}

lecture-3/demo308-const.cpp

Static members

  • Static members belong to the class (i.e. every object), as opposed to a particular object.
  • 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)

Static member functions

// For use with a database
class user {
  public:
	user(std::string const& name) : name_{name} {}
	static auto valid_name(std::string const& name) -> bool {
		return name.length() < 20;
	}
  private:
	std::string name_;
}

auto main() -> int {
	auto n = std::string{"Santa Clause"};
	if (user::valid_name(n)) {
		user user1{n};
	}
}

lecture-3/demo309-static.cpp

// For use with a database
class user {
  public:
	user(std::string const& name) : name_{name} {}
	auto valid_name(std::string const& name) -> bool {
		return name.length() < 20;
	}
  private:
	std::string name_;
}

auto main() -> int {
	auto n = std::string{"Santa Clause"};
    auto u = user{};
	if (u.valid_name(n)) {
		user user1{n};
	}
}

Static member fields

Static member fields are usually defined outside of the class scope. This will be explored in your tutorial

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
int main() {
  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_;
};

Deleting unused default member fns

  • Revise "The synthesized default constructor"
  • There are several special functions that we must consider when designing classes
  • Ask yourself the question:
    • Does it make sense to have this default member function?
      • Yes: Does the compile synthesised function make sense?
        • No: write your own definition
        • Yes: write "<function declaration> = default;"
      • No: write "<function declaration> = delete;"

Default & Delete

#include <vector>

class intvec {
public:
	// This one allows the implicit conversion
	explicit intvec(std::vector<int>::size_type length)
	: vec_(length, 0) {}
	// intvec(intvec const& v) = default;
	// intvec(intvec const& v) = delete;

private:
	std::vector<int> vec_;
};

auto main() -> int {
	intvec a{4};
	// intvec b{a}; // Will this work?
}

lecture-3/demo311-delete.cpp

Let's look at an example regarding the copy constructor

(Optional) Bookstore

Explore demo300 in the lecture repo. The style will not be appropriate as the style is from an older offering of the course.

Feedback

T222 COMP6771 22T2 - 3.1 - Class Types

By imranrazzak

T222 COMP6771 22T2 - 3.1 - Class Types

  • 557