🤖 Classes and Objects - Part 2
In the previous lesson on Classes and Objects, we learned how to define a class (a blueprint) with public fields and methods, and how to create objects (instances) from that class. However, directly exposing fields as public
isn’t always the best practice.
This lesson builds upon that foundation, introducing:
- Access Modifiers (
private
): Controlling visibility of class members. - Encapsulation: The principle of bundling data and methods, and hiding internal details.
- Constructors: Special methods for initializing objects when they are created.
- Properties: Controlled access points to an object’s data (using
get
andset
).
(Prerequisite: Ensure you understand the concepts from the “Classes and Objects” post.)
Lesson: Better Blueprints - Encapsulation and Control
Why Not Just Use Public Fields?
In the previous example, we made fields like Name
and Breed
in the Dog
class public
. This works, but it has drawbacks:
- No Validation: Anyone using the
Dog
object can set the fields to any value, even invalid ones (e.g., settingage
to -50). - Lack of Control: If we later decide to change how the data is stored internally (e.g., combine first and last names into one field), any code directly accessing the old public fields will break.
- Breaks Encapsulation: The internal state of the object is wide open, making the code harder to maintain and reason about.
Object-Oriented Programming promotes Encapsulation, which means bundling an object’s data (state) and the methods that operate on that data (behavior) together, while hiding the internal implementation details from the outside world.
Access Modifiers: public
vs. private
To control visibility, C# uses access modifiers:
public
: The member (field, method, property, class) can be accessed from anywhere.private
: The member can only be accessed from within the same class. This is the default if you don’t specify an access modifier for a member inside a class.
Good practice often involves making fields private
to protect the object’s internal state.
1
2
3
4
5
6
7
8
9
public class Player
{
// Fields are now private
private string _name;
private int _health;
private int _score;
// How do we access _name, _health, _score from outside now?
}
Naming Convention: It’s common to prefix private fields with an underscore (_
) followed by camelCase (e.g., _name
, _health
).
Constructors: Initializing Objects Properly
When we create an object using new ClassName()
, a special method called a constructor is automatically called. Its job is to initialize the object’s state.
- A constructor has the same name as the class.
- It has no return type (not even
void
). - It can take parameters, just like regular methods.
If you don’t define any constructor, C# provides a default, parameterless one that does nothing. But we can define our own to ensure objects are created with valid initial data.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Player
{
private string _name;
private int _health;
private int _score;
// Constructor
public Player(string initialName, int initialHealth)
{
_name = initialName; // Use parameter to set private field
_health = initialHealth; // Use parameter to set private field
_score = 0; // Initialize score to 0 by default
Console.WriteLine($"Player {_name} created with {_health} health.");
}
// Other methods...
}
Now, when you create a Player
object, you must provide the required arguments:
1
2
3
4
// Player player1 = new Player(); // Error! No matching constructor without parameters.
Player player1 = new Player("Alice", 100); // Calls the constructor
Player player2 = new Player("Bob", 80);
Constructors ensure that objects start in a valid state.
Properties: The Gatekeepers of Data
Since our fields are private
, how do we allow controlled access? We use Properties. Properties look like fields when you use them (e.g., player1.Name
), but they are actually special methods called accessors (get
and set
) hidden behind the scenes.
- The
get
accessor runs when you read the property’s value. It should return the value of the underlying private field. - The
set
accessor runs when you assign a value to the property. It receives the assigned value in a special implicit parameter calledvalue
. Here, you can add validation logic before updating the private field.
Properties use PascalCase naming convention.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
public class Player
{
private string _name;
private int _health;
private int _score;
// Constructor
public Player(string initialName, int initialHealth)
{
// Use properties inside constructor for validation (if needed)
Name = initialName; // Calls the 'set' accessor of Name property
_health = (initialHealth > 0) ? initialHealth : 100; // Basic validation for health
_score = 0;
Console.WriteLine($"Player {Name} created with {_health} health.");
}
// Property for Name (Read and Write)
public string Name
{
get
{
return _name; // Returns the value of the private field
}
set
{
// 'value' holds the incoming value (e.g., "Alice")
if (!string.IsNullOrEmpty(value)) // Basic validation: name cannot be empty
{
_name = value; // Update the private field
}
else
{
Console.WriteLine("Player name cannot be empty.");
}
}
}
// Property for Health (Read-only from outside)
public int Health
{
get { return _health; }
// No 'set' accessor means health cannot be directly changed from outside
// It can only be changed by methods within the class (like TakeDamage)
}
// Property for Score (Read-only from outside)
public int Score
{
get { return _score; }
private set { _score = value; } // 'private set' allows setting only from within the class
}
// Example of trying to use private set from outside (will cause error)
// Player tempPlayer = new Player("Test", 50);
// tempPlayer.Score = 10; // Error! The property 'Player.Score' cannot be used in this context because the set accessor is inaccessible
// Methods to modify state internally
public void TakeDamage(int amount)
{
if (amount > 0)
{
_health -= amount;
if (_health < 0) _health = 0;
Console.WriteLine($"{Name} took {amount} damage! Health: {_health}");
}
}
public void AddScore(int points)
{
if (points > 0)
{
Score += points; // Use the 'private set' accessor of the Score property
Console.WriteLine($"{Name} scored {points} points! Score: {Score}");
}
}
}
Now, you interact with the properties:
1
2
3
4
5
6
7
8
9
10
11
12
13
Player player1 = new Player("Alice", 100);
Console.WriteLine("Initial Name: " + player1.Name); // Uses 'get' accessor
player1.Name = "Alicia"; // Uses 'set' accessor
Console.WriteLine("New Name: " + player1.Name);
// player1.Health = 150; // Error! Health property has no public 'set' accessor.
player1.TakeDamage(20); // Health is modified internally by the method
Console.WriteLine("Current Health: " + player1.Health); // Uses 'get' accessor
player1.AddScore(50); // Uses the AddScore method, which uses the private 'set' of Score
Console.WriteLine("Current Score: " + player1.Score);
Properties are the standard way in C# to expose object data in a controlled and flexible manner, supporting the principle of encapsulation.
Tutorial: Building a Better BankAccount
Let’s revisit the BankAccount
example from the previous tutorial and improve it using private
fields, a constructor, and properties.
Objective: Refactor the BankAccount
class to use encapsulation principles.
Prerequisites: A C# console project. You can modify the BankApp
project or create a new one.
Step 1: Refactor BankAccount.cs
- Fields and Constructor
Modify BankAccount.cs
. Make the fields private
and ensure the constructor initializes them.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// BankAccount.cs
using System;
public class BankAccount
{
// Private fields
private string _accountNumber;
private decimal _balance;
// Constructor
public BankAccount(string accountNumber, decimal initialBalance)
{
_accountNumber = accountNumber; // Assume account number is valid for now
// Validate initial balance
if (initialBalance >= 0)
{
_balance = initialBalance;
}
else
{
_balance = 0; // Default to 0 if initial balance is negative
Console.WriteLine("Initial balance cannot be negative. Setting to 0.");
}
Console.WriteLine($"Account {_accountNumber} created with balance: {_balance:C}");
}
// Properties and Methods will go here...
}
Step 2: Add Properties
Add public properties for AccountNumber
(read-only) and Balance
(read-only).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// BankAccount.cs
public class BankAccount
{
// ... Fields and Constructor ...
// Property for AccountNumber (Read-only)
public string AccountNumber
{
get { return _accountNumber; }
}
// Property for Balance (Read-only)
public decimal Balance
{
get { return _balance; }
// No 'set' accessor - balance changes only via Deposit/Withdraw methods
}
// Methods will go here...
}
Step 3: Update Methods
The Deposit
and Withdraw
methods already work with the private _balance
field, so they don’t need significant changes, but ensure they use _balance
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// BankAccount.cs
public class BankAccount
{
// ... Fields, Constructor, Properties ...
// Method to deposit funds
public void Deposit(decimal amount)
{
if (amount > 0)
{
_balance += amount; // Modify private field
Console.WriteLine($"Deposited {amount:C}. New balance: {_balance:C}");
}
else
{
Console.WriteLine("Deposit amount must be positive.");
}
}
// Method to withdraw funds
public bool Withdraw(decimal amount)
{
if (amount <= 0)
{
Console.WriteLine("Withdrawal amount must be positive.");
return false;
}
if (amount > _balance) // Check against private field
{
Console.WriteLine("Insufficient funds.");
return false;
}
_balance -= amount; // Modify private field
Console.WriteLine($"Withdrew {amount:C}. New balance: {_balance:C}");
return true;
}
}
Step 4: Test in Program.cs
The code in Program.cs
to use the BankAccount
class remains largely the same, as it was already interacting via the constructor, methods, and (now) properties.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Program.cs (Should still work as before)
using System;
public class Program
{
public static void Main(string[] args)
{
Console.WriteLine("Welcome to Simple Bank!");
BankAccount myAccount = new BankAccount("ACC12345", 100.00m);
BankAccount invalidAccount = new BankAccount("ACC67890", -50.00m); // Test negative initial balance
Console.WriteLine($"Account Holder: {myAccount.AccountNumber}"); // Uses property
Console.WriteLine($"Initial Balance: {myAccount.Balance:C}"); // Uses property
Console.WriteLine($"\n--- Transactions for {myAccount.AccountNumber} ---");
myAccount.Deposit(50.50m);
myAccount.Withdraw(30.00m);
myAccount.Withdraw(200.00m);
myAccount.Deposit(-10.00m);
Console.WriteLine("\n--- Final Status ---");
Console.WriteLine($"Final Balance for {myAccount.AccountNumber}: {myAccount.Balance:C}");
Console.WriteLine($"Final Balance for {invalidAccount.AccountNumber}: {invalidAccount.Balance:C}");
}
}
Step 5: Build and Run
Save both files, build (dotnet build
), and run (dotnet run
). Verify the output, including the handling of the negative initial balance.
Expected Output:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Welcome to Simple Bank!
Account ACC12345 created with balance: $100.00
Initial balance cannot be negative. Setting to 0.
Account ACC67890 created with balance: $0.00
Account Holder: ACC12345
Initial Balance: $100.00
--- Transactions for ACC12345 ---
Deposited $50.50. New balance: $150.50
Withdrew $30.00. New balance: $120.50
Insufficient funds.
Deposit amount must be positive.
--- Final Status ---
Final Balance for ACC12345: $120.50
Final Balance for ACC67890: $0.00
This tutorial demonstrated how to refactor a class to use private
fields, a constructor for initialization, and public
properties for controlled access, adhering to the principle of encapsulation.
Exercise: Refactor the Product
Class
Let’s apply these concepts to the Product
class from the previous exercises.
Project Goal: Refactor the Product
class to use private fields, a constructor, and public properties with appropriate access control.
Requirements:
- Start with the
Product
class andProgram.cs
from the previous exercises. - Modify the
Product
class:- Change all fields (
_productId
,_name
,_price
,_quantityInStock
) to beprivate
(use the_
prefix convention). - Ensure the constructor correctly initializes these private fields.
- Replace the public fields with public properties (
ProductId
,Name
,Price
,QuantityInStock
). ProductId
andName
should be read-only after creation (onlyget
accessor).Price
should be read/write, but theset
accessor should prevent setting a negative price (if negative, maybe default to 0 or keep the old price and print a warning).QuantityInStock
should be read-only from outside, but you should add a public methodUpdateStock(int change)
that modifies the private_quantityInStock
field (allow positive or negative changes, but perhaps prevent stock going below zero).
- Change all fields (
- Modify the
DisplayProductDetails()
method to use the properties (e.g.,Console.WriteLine("Name: " + Name);
). - Modify
Program.cs
:- Ensure it uses the constructor to create
Product
objects. - Ensure it uses properties (e.g.,
product1.Price
) instead of fields to access data. - Try setting an invalid price (e.g.,
product1.Price = -10;
) and see if your validation works. - Call the new
UpdateStock()
method. - Call
DisplayProductDetails()
to see the final state.
- Ensure it uses the constructor to create
Hints:
- Property with validation in
set
:public decimal Price { get { return _price; } set { if (value >= 0) _price = value; else Console.WriteLine("Price cannot be negative."); } }
- Method to update stock:
public void UpdateStock(int change) { /* add logic here */ }
Steps:
- Refactor
Product.cs
with private fields, constructor, properties (with validation), and theUpdateStock
method. - Update
Program.cs
to use the constructor and properties/methods. - Save, build, and run.
- Test creating products, setting an invalid price, updating stock, and displaying details.
Conclusion
This lesson significantly improved our class design by introducing encapsulation using private
fields and public
properties. We learned how constructors ensure proper object initialization and how properties (get
/set
accessors) provide controlled access to an object’s state, allowing for validation and flexibility. These techniques are crucial for building robust, maintainable object-oriented applications in C#.