OOP Search

Week 04 / 14 Object-Oriented Programming

Lab 4 - Constructors, destructors and object lifetime

We turn the BankAccount from the repository into a class that is valid from the moment it is created and cleans up after itself. Then we watch when objects are born and when they die.

Objectives
  • Write a default constructor and a parameterized constructor
  • Validate the arguments so the object is never in an invalid state
  • Write a destructor that releases the memory the object owns
  • Mark the methods that do not change the object as const
  • Predict the order of constructor and destructor calls in nested scopes
  • Explain the cost of passing an object by value instead of by const reference

Plan (90 min)

TimeActivity
0-5Recap through questions: what is an invariant?
5-15Read the starting program and predict what it prints
15-25Demo: one constructor and the destructor, with logging
25-55Individual work: constructors, validation, destructor, const methods
55-70Experiments: lifetime in nested scopes, by value vs by reference
70-80Requirement change
80-90Individual checks and short oral questions

Starting program

The code is in the repository, in the L04 folder. BankAccount is still a struct that is filled in by initBankAccount, and nothing ever frees its memory.

L04/lab04.cpp
// data - variables and behaviour - functions
// model a class = a req (stud, bank acc) and implement a class which has behav and data -> represent the real life thing into the code 
#include <iostream>

using namespace std;

struct BankAccount
{
	char* name = nullptr;
	string id = "default id";
	float* transactions = nullptr;
	int nrTransactions = 0;
};

BankAccount initBankAccount(string name, string id, float* transactions, int nrTransactions)
{
	BankAccount ba;
	ba.name = new char[strlen(name.c_str()) + 1];
	strcpy_s(ba.name, name.length() + 1, name.c_str());
	ba.id = id; //uuid generator - extra stuf

	ba.nrTransactions = nrTransactions;
	ba.transactions = new float[nrTransactions];
	for (int i = 0; i < ba.nrTransactions; i++)
	{
		ba.transactions[i] = transactions[i];
	}

	return ba;
}

void printBankAccount(BankAccount ba)
{
	if (ba.name != nullptr)	// how to avoid crashing the program; it's a guard
		cout << ba.name;
	cout << endl;
	cout << ba.id << endl;
	cout << ba.nrTransactions << endl;   // garbage values or 0 
	for (int i = 0; i < ba.nrTransactions; i++)
	{
		cout << ba.transactions[i] << ' ';
	}
	cout << endl;
}

void topUp(BankAccount ba, float amount)
{
	for (int i = 0; i < ba.nrTransactions;i++)
	{
		if(ba.transactions[i] == 0)
		{
			ba.transactions[i] = amount;
			break;
		}
	}
}

void incrementId(BankAccount& ba)
{
	ba.id = ba.id + "1";
}

int main()
{
	string name = "Catalin Popescu";
	string name2 = "Manole Ion";
	BankAccount ba;
	BankAccount ba2;

	int nrTransactions = 10;
	float* transactions = new float[nrTransactions];
	
	for (int i = 0; i < nrTransactions; i++)
	{
		transactions[i] = 0;
	}

	//ba.name = new char[strlen(name.c_str()) + 1];
	//strcpy_s(ba.name, name.length() + 1, name.c_str());
	//ba.id = "b3aeb6fc-3eeb-48e6-b938-c6a5cc1a8ca7"; //uuid generator - extra stuff
	//

	//ba.nrTransactions = nrTransactions;
	//ba.transactions = new float[nrTransactions];
	//for (int i = 0; i < ba.nrTransactions; i++)
	//{
	//	ba.transactions[i] = transactions[i];
	//}

	ba = initBankAccount(name, "b3aeb6fc-3eeb-48e6-b938-c6a5cc1a8ca7", transactions, 
		nrTransactions);
	ba2 = initBankAccount(name2, "2a565b1f-9eb8-4ba5-9f83-e4def2b262dc", transactions,
		nrTransactions);
	//if(ba.name != nullptr)	// how to avoid crashing the program; it's a guard
	//	cout << ba.name;
	//cout << endl;
	//cout << ba.id << endl;
	//cout << ba.nrTransactions << endl;   // garbage values or 0 
	//for (int i = 0; i < ba.nrTransactions; i++)
	//{
	//	cout<< ba.transactions[i] << ' ';
	//}
	//cout << endl;

	printBankAccount(ba);
	cout << endl;
	printBankAccount(ba2);
	cout << endl;
	topUp(ba2, 100);
	printBankAccount(ba2);
	cout << endl;
	incrementId(ba2);
	printBankAccount(ba2);

	return 0;
}

Before you run it: topUp takes the account by value, yet the top-up shows up in ba2. Why? The program below is a smaller version of the same thing.

#include <iostream>
using namespace std;
struct Wallet {
    float* transactions;
    int n;
};
void topUp(Wallet w, float amount) {
    w.transactions[0] = amount;
    w.n = 99;
}
int main() {
    float t[3] = {0, 0, 0};
    Wallet w = {t, 3};
    topUp(w, 100);
    cout << w.transactions[0] << " " << w.n;
}

The copy w has its own n, but its pointer holds the same address as the original. Write that down; you will need it in the second experiment.

From init function to constructors

A BankAccount that is always valid

Turn the struct into a class with private members. Replace initBankAccount with constructors:

  • a default constructor: empty name, id "default id", no transactions;
  • a parameterized constructor BankAccount(const char* name, string id, const float* t, int nr) that copies the name and the transactions into memory it allocates itself;
  • a destructor that frees both arrays.

A negative nr or a null t gives an account with 0 transactions. print and getBalance don’t change the account, so make them const.

HintAsk yourself first: which states must be impossible? A negative nrTransactions? A name that is nullptr?
HintAllocate the name even in the default constructor (new char[1] holding '\0'). Then the destructor can always call delete[] name, and print needs no guard.
Solution
BankAccount(const char* n, string id, const float* t, int nr) : id(id) {
    if (n == nullptr) n = "";
    name = new char[strlen(n) + 1];
    strcpy(name, n);
    if (nr < 0 || t == nullptr) nr = 0;
    nrTransactions = nr;
    transactions = nr > 0 ? new float[nr] : nullptr;
    for (int i = 0; i < nr; i++) transactions[i] = t[i];
}

~BankAccount() {
    delete[] name;
    delete[] transactions;
}

The object owns both arrays, so the destructor frees them. delete[] on nullptr does nothing, so an empty account is safe to destroy.

Experiment 1: lifetime in nested scopes

Add a temporary cout to the constructor and the destructor. Before running, write down the order you expect.

#include <iostream>
#include <string>
using namespace std;
class Account {
    string owner;
public:
    Account(string o) : owner(o) { cout << "Constructor " << owner << endl; }
    ~Account() { cout << "Destructor " << owner << endl; }
};
int main() {
    Account p1("p1");
    {
        Account p2("p2");
        Account p3("p3");
    }
    Account p4("p4");
}

Experiment 2: by value or by const reference

#include <iostream>
#include <string>
using namespace std;
class Account {
    string owner;
public:
    Account(string o) : owner(o) { cout << "Constructor " << owner << endl; }
    Account(const Account& a) : owner(a.owner) { cout << "Copy " << owner << endl; }
    ~Account() { cout << "Destructor " << owner << endl; }
};
void processByValue(Account a) { cout << "processing" << endl; }
void processByReference(const Account& a) { cout << "processing" << endl; }
int main() {
    Account x("x");
    processByValue(x);
    processByReference(x);
}

Now go back to your BankAccount, which has a destructor but no copy constructor. If printBankAccount still takes it by value, the copy shares the two arrays (remember the Wallet), and its destructor frees them when the function returns. The original is left with dangling pointers, and its own destructor frees them a second time. That is a double delete: wrong, even if the program happens not to crash. Change the parameter to const BankAccount&.

Mandatory questions

  1. Which constructor is called here?
  2. Why did another constructor/destructor appear?
  3. What is the cost of pass-by-value?
  4. When is a reference preferable?
  5. Who owns this memory, and who frees it?

Minimum task

Constructors, validation, destructor, const

Complete the class. topUp rejects amounts <= 0 and writes into the first slot that is still 0; it returns false when it cannot.

#include <iostream>
#include <cstring>
#include <string>
using namespace std;

class BankAccount {
  char* name;
  string id;
  float* transactions;
  int nrTransactions;

public:
  BankAccount() : name(nullptr), id("default id"), transactions(nullptr), nrTransactions(0) {
      // TODO: name should be an empty string, not nullptr
  }

  BankAccount(const char* n, string id, const float* t, int nr)
      : name(nullptr), id(id), transactions(nullptr), nrTransactions(0) {
      // TODO: copy n and t into memory you allocate; reject nr < 0
  }

  // TODO: destructor that releases name and transactions

  void print() const {
      if (name != nullptr) cout << name;
      cout << endl << id << endl;
      for (int i = 0; i < nrTransactions; i++) cout << transactions[i] << ' ';
      cout << endl;
  }

  float getBalance() const {
      // TODO
      return 0;
  }

  bool topUp(float amount) {
      // TODO: reject amount <= 0, fill the first free (0) slot
      return false;
  }
};

int main() {
  float t[] = {50, 0, 0};
  BankAccount a("Catalin Popescu", "b3ae", t, 3);
  cout << a.topUp(100) << a.topUp(-5) << endl;   // expected: 10
  a.print();                                     // expected: Catalin Popescu / b3ae / 50 100 0
  cout << a.getBalance() << endl;                // expected: 150
  BankAccount empty;
  empty.print();
}
Input (stdin)

Open in Godbolt

Requirement change

The bank wants to know how many accounts exist at any moment. Add a static int counter, increase it in every constructor and decrease it in the destructor, and expose it through static int getCount(). Check it with an account created inside an inner block: what does the counter show inside the block, and after it?

Code in repository: L04

Romanian version