Abstraction and Abstract Classes
By the end of this lesson
Define partial implementations that subclasses must complete.
Sometimes you know most of how an operation goes, and one step differs every time. Every payroll deduction is capped at the employee's gross pay and can never be negative — that part is settled. How much each particular deduction comes to is not.
An abstract class lets you write down the settled part once and leave a labelled hole for the rest. A class marked abstract cannot be created directly; it exists to be completed by subclasses. A member marked abstract has no body at all, and every subclass must supply one.
public abstract class Deduction
{
public string Name { get; }
protected Deduction(string name)
{
Name = name;
}
// Settled for every deduction: never negative, never more than gross pay.
public decimal AmountFor(decimal grossMonthlyPay)
{
decimal raw = CalculateRaw(grossMonthlyPay);
if (raw < 0m)
{
return 0m;
}
return Math.Min(raw, grossMonthlyPay);
}
// Left to subclasses: how much this deduction actually is.
protected abstract decimal CalculateRaw(decimal grossMonthlyPay);
}
public class ProvidentFundDeduction : Deduction
{
public ProvidentFundDeduction() : base("Provident fund") { }
protected override decimal CalculateRaw(decimal grossMonthlyPay)
{
return grossMonthlyPay * 0.12m;
}
}
public class FixedFeeDeduction : Deduction
{
private readonly decimal _amount;
public FixedFeeDeduction(string name, decimal amount) : base(name)
{
_amount = amount;
}
protected override decimal CalculateRaw(decimal grossMonthlyPay)
{
return _amount;
}
}- abstract class Deduction cannot be instantiated. new Deduction("anything") does not compile, which is correct — there is no such thing as a deduction in general.
- The constructor is protected rather than public. Only subclasses need to call it, and marking it protected says so.
- protected abstract decimal CalculateRaw(...) declares a method with no body. Any concrete subclass must override it or it will not compile — the compiler enforces completion for you.
- AmountFor is ordinary and not virtual. The cap and the floor apply to every deduction and subclasses cannot opt out. This shape, where the base controls the sequence and subclasses fill in steps, is often called a template method.
- Math.Min returns the smaller of two values, which caps the deduction at gross pay.
What abstract actually changes:
- abstract on a class
- The class cannot be created directly. It can still have constructors, fields, properties and fully written methods — it is a partly built class, not an empty one.
- abstract on a member
- No body, and every concrete subclass must provide one. It is implicitly virtual, so you write override in the subclass.
- Concrete members alongside
- The reason to choose an abstract class over an interface. Shared state and shared working code live here, written once.
- protected members
- Visible to subclasses and to nobody else. This is how you offer subclasses the pieces they need without widening the public surface.
Abstract class or interface? The next lesson covers interfaces in full; the distinction is worth having now.
| Abstract class | Interface | |
|---|---|---|
| Can contain written code | Yes, and usually should | Only default implementations, which are a last resort |
| Can hold state (fields) | Yes | No |
| How many can a type have | One | As many as you like |
| Relationship expressed | Is a kind of | Can do this |
| Adding a member later | Can be non-breaking if you give it a body | Breaks every implementer unless you supply a default |
| Reach for it when | Subclasses share real implementation | You need a contract, or several unrelated types must qualify |
Summary
- An abstract class is a partly written class that cannot be created directly
- An abstract member has no body, and the compiler forces every concrete subclass to supply one
- Keeping the sequence in a non-virtual base method makes the shared rule impossible for subclasses to bypass
- Choose an abstract class when there is real shared implementation; choose an interface when there is only a contract
- The costs are the single base-class slot, inverted control, and a base class you cannot instantiate in tests
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Add a ProfessionalTaxDeduction of 200 per month, and write a small loop that applies a list of deductions to a gross pay of 45,000 and prints each name and amount plus the net figure.
Then create a deduction whose CalculateRaw returns a number larger than gross pay, and confirm you cannot make the net pay negative.
Show solution
The new deduction is one class and one method, because everything else was already decided. Adding the twelfth deduction costs the same as adding the second.
The oversized deduction is the part worth dwelling on. CalculateRaw is allowed to return anything, and the cap in AmountFor still holds, because AmountFor is not virtual. A subclass author cannot break the rule even by trying — which is a stronger guarantee than documenting it and hoping.
public class ProfessionalTaxDeduction : Deduction
{
public ProfessionalTaxDeduction() : base("Professional tax") { }
protected override decimal CalculateRaw(decimal grossMonthlyPay)
{
return 200m;
}
}
decimal gross = 45_000m;
List<Deduction> deductions = new List<Deduction>
{
new ProvidentFundDeduction(),
new ProfessionalTaxDeduction(),
new FixedFeeDeduction("Canteen", 1_500m)
};
decimal net = gross;
foreach (Deduction deduction in deductions)
{
decimal amount = deduction.AmountFor(gross);
net -= amount;
Console.WriteLine($"{deduction.Name,-18} {amount,10:N2}");
}
Console.WriteLine($"{"Net pay",-18} {net,10:N2}");Think about it
Think about it
Your abstract base class has grown three protected boolean properties — SkipCap, ApplyBeforeTax and RoundToRupee — that subclasses set to change how the shared method behaves.
What is that arrangement telling you, and what would you consider instead?
Show solution
Each flag is a branch in the shared method, so three flags describe up to eight different behaviours living in one place. The method is no longer shared implementation; it is a small interpreter for subclass configuration.
It also becomes untestable in a useful way. You cannot reason about the base method without knowing which combination of flags a given subclass chose, and nothing stops a nonsensical combination.
Options worth weighing: pull each varying step into its own abstract or virtual member so subclasses override behaviour rather than set switches; or split the base class if the flags cluster into two genuinely different sequences; or drop to an interface plus a couple of small helper classes that subclasses compose, which is often where this ends up.
There is no single right answer, and the flags are not automatically wrong — one flag for one genuine variation can be the plainest thing to read. It is the accumulation that signals the abstraction is carrying more than it should.
Saved in this browser only.