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.
- 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)
| Time | Activity |
|---|---|
| 0-5 | Recap through questions: what is an invariant? |
| 5-15 | Read the starting program and predict what it prints |
| 15-25 | Demo: one constructor and the destructor, with logging |
| 25-55 | Individual work: constructors, validation, destructor, const methods |
| 55-70 | Experiments: lifetime in nested scopes, by value vs by reference |
| 70-80 | Requirement change |
| 80-90 | Individual 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.
// 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.
Hint
Ask yourself first: which states must be impossible? A negativenrTransactions? A name that is nullptr?Hint
Allocate 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
- Which constructor is called here?
- Why did another constructor/destructor appear?
- What is the cost of pass-by-value?
- When is a reference preferable?
- 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)
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?