Lesson 20 min

Encapsulation & Polymorphism

00:00 / 00:00

Encapsulation & Polymorphism

Two of the four pillars of Object-Oriented Programming — Encapsulation and Polymorphism — help you write code that is safer, more flexible, and easier to maintain. In this lesson, we'll explore both concepts with practical Dart examples.


Part 1: Encapsulation

Encapsulation is the idea of bundling data (fields) and behavior (methods) together inside a class, while controlling how that data is accessed or modified from the outside world.

Think of it like a TV remote — you press a button and the TV changes channel. You don't need to know the internal circuitry. The remote encapsulates the complexity.


Private Members with Underscore Prefix

In Dart, there is no private keyword. Instead, any identifier that starts with an underscore (_) is private to the library (i.e., the file) it's defined in.

dart
class BankAccount { String owner; double _balance; // private — cannot be accessed outside this file BankAccount(this.owner, this._balance); }

If you try to access _balance from another file, Dart will throw a compile-time error.

dart
// In another file: var account = BankAccount('Alice', 1000.0); print(account._balance); // ❌ Error: '_balance' is private

Getters and Setters

To allow controlled access to private fields, Dart provides getters and setters.

dart
class BankAccount { String owner; double _balance; BankAccount(this.owner, this._balance); // Getter — read-only access double get balance => _balance; // Setter — validated write access set balance(double amount) { if (amount < 0) { throw ArgumentError('Balance cannot be negative'); } _balance = amount; } void deposit(double amount) { if (amount <= 0) throw ArgumentError('Deposit must be positive'); _balance += amount; } void withdraw(double amount) { if (amount > _balance) throw StateError('Insufficient funds'); _balance -= amount; } } void main() { var account = BankAccount('Alice', 500.0); print(account.balance); // ✅ 500.0 — via getter account.deposit(200.0); print(account.balance); // ✅ 700.0 account.balance = 1000.0; // ✅ uses setter // account.balance = -50.0; // ❌ throws ArgumentError account.withdraw(300.0); print(account.balance); // ✅ 700.0 }

[!TIP] Use getters when you want to expose a computed or formatted value. Use setters when you want to validate data before it's stored.


Computed Getters

Getters don't have to return a stored field — they can compute a value on the fly:

dart
class Circle { double _radius; Circle(this._radius); double get radius => _radius; // Computed getter — no stored field double get area => 3.14159 * _radius * _radius; double get circumference => 2 * 3.14159 * _radius; set radius(double r) { if (r <= 0) throw ArgumentError('Radius must be positive'); _radius = r; } } void main() { var c = Circle(5.0); print('Area: \${c.area}'); // Area: 78.53975 print('Circumference: \${c.circumference}'); // Circumference: 31.4159 }

Why Encapsulation Matters

BenefitExplanation
Data ProtectionPrevents external code from putting your object in an invalid state
API ContractsYou can change internal implementation without breaking callers
ValidationSetters allow you to enforce rules on data
ReadabilityClear separation between what's public (interface) and what's private (implementation)

Dart's Library-Level Privacy

Unlike Java or C# where privacy is class-level, Dart's _ privacy is library-level (file-level). This means:

  • Any code in the same file can access _ members.
  • Code in other files cannot, even subclasses in other files.
dart
// file: person.dart class Person { String _name; int _age; Person(this._name, this._age); String get name => _name; int get age => _age; // Can access _name inside same file String _formatName() => 'Mr./Ms. \$_name'; } // Also in person.dart — same library, so _name is accessible class PersonHelper { static void printDetails(Person p) { print(p._name); // ✅ same file = same library } }

[!NOTE] Dart's library-level privacy is simpler than Java's class-level private. It encourages grouping related classes in the same file when they need to share internals.


Part 2: Polymorphism

Polymorphism means "many forms." In OOP, it's the ability of different objects to respond to the same method call in different ways.

There are two main types:

  • Compile-time polymorphism (method overloading — Dart doesn't support this)
  • Runtime polymorphism (method overriding — Dart fully supports this ✅)

Method Overriding with @override

When a subclass provides its own implementation of a method already defined in its superclass, that's method overriding.

dart
class Animal { String name; Animal(this.name); void speak() { print('\$name makes a sound.'); } } class Dog extends Animal { Dog(String name) : super(name); void speak() { print('\$name says: Woof! 🐕'); } } class Cat extends Animal { Cat(String name) : super(name); void speak() { print('\$name says: Meow! 🐈'); } } class Duck extends Animal { Duck(String name) : super(name); void speak() { print('\$name says: Quack! 🦆'); } }

The @override annotation tells Dart (and other developers) that you're intentionally overriding a parent method. If the method name doesn't exist in the parent, Dart will warn you.


Runtime Polymorphism Example

The real power of polymorphism is that you can use a parent type to hold child objects, and the correct method is called at runtime:

dart
void main() { List<Animal> animals = [ Dog('Rex'), Cat('Whiskers'), Duck('Donald'), Dog('Buddy'), ]; for (var animal in animals) { animal.speak(); // Correct method called based on actual type } }

Output:

Rex says: Woof! 🐕
Whiskers says: Meow! 🐈
Donald says: Quack! 🦆
Buddy says: Woof! 🐕

Each object responds to speak() differently, even though we're calling it through the Animal type. This is runtime polymorphism in action.


Using Parent Type to Reference Child Objects

You can declare a variable as the parent type but assign a child object:

dart
Animal myPet = Dog('Max'); // Animal variable, Dog object myPet.speak(); // Max says: Woof! 🐕 myPet = Cat('Luna'); // Now it's a Cat myPet.speak(); // Luna says: Meow! 🐈

This is useful when writing generic code that works with any subtype.


Type Checks with is and as

Sometimes you need to check the actual runtime type of an object. Dart provides is and as:

dart
void processAnimal(Animal animal) { print('Processing: \${animal.name}'); animal.speak(); // Type check with 'is' if (animal is Dog) { print(' → This is a dog. Fetching stick...'); // After 'is Dog' check, Dart auto-promotes type animal.speak(); // No cast needed inside the if block } if (animal is Cat) { print(' → This is a cat. Offering milk...'); } } void main() { List<Animal> animals = [Dog('Rex'), Cat('Mimi'), Duck('Quacky')]; for (var a in animals) { processAnimal(a); print('---'); } }

Force casting with as:

dart
Animal animal = Dog('Rex'); // Force cast — only safe if you're sure of the type Dog dog = animal as Dog; dog.speak(); // Unsafe cast — will throw at runtime // Cat cat = animal as Cat; // ❌ CastError: Dog is not Cat

[!WARNING] Using as without an is check first can throw a CastError at runtime. Always prefer the is check and rely on Dart's type promotion.


Real Example: Shape Hierarchy with area()

Let's combine encapsulation and polymorphism in a classic Shape hierarchy:

dart
import 'dart:math'; abstract class Shape { String _color; Shape(this._color); // Getter for encapsulated color String get color => _color; // Abstract method — every subclass must implement double area(); // Concrete method — shared behavior void describe() { print('A \$_color \${runtimeType} with area: \${area().toStringAsFixed(2)}'); } } class Circle extends Shape { double _radius; Circle(String color, this._radius) : super(color); double get radius => _radius; double area() => pi * _radius * _radius; } class Rectangle extends Shape { double _width; double _height; Rectangle(String color, this._width, this._height) : super(color); double get width => _width; double get height => _height; double area() => _width * _height; } class Triangle extends Shape { double _base; double _height; Triangle(String color, this._base, this._height) : super(color); double area() => 0.5 * _base * _height; } class Square extends Rectangle { Square(String color, double side) : super(color, side, side); double area() => width * width; void describe() { print('A \$color Square with side: \$width and area: \${area().toStringAsFixed(2)}'); } } void printShapeInfo(Shape shape) { shape.describe(); if (shape is Circle) { print(' Radius: \${shape.radius}'); } else if (shape is Rectangle) { print(' Width: \${shape.width}, Height: \${shape.height}'); } } void main() { List<Shape> shapes = [ Circle('red', 7.0), Rectangle('blue', 4.0, 6.0), Triangle('green', 3.0, 8.0), Square('yellow', 5.0), ]; print('=== Shape Report ===\n'); for (var shape in shapes) { printShapeInfo(shape); print(''); } // Find total area — polymorphism makes this trivial double totalArea = shapes.fold(0, (sum, s) => sum + s.area()); print('Total area of all shapes: \${totalArea.toStringAsFixed(2)}'); }

Output:

=== Shape Report ===

A red Circle with area: 153.94
  Radius: 7.0

A blue Rectangle with area: 24.00
  Width: 4.0, Height: 6.0

A green Triangle with area: 12.00

A yellow Square with side: 5.0 and area: 25.00

Total area of all shapes: 214.94

Notice how for (var shape in shapes) { shape.describe(); } calls the right method for each shape without any if statements. That's the elegance of polymorphism.


Summary

ConceptKey Idea
EncapsulationHide internal state, expose only what's needed
_ prefixMakes a member private to its library (file)
GettersRead-only or computed access to private fields
SettersValidated write access to private fields
PolymorphismSame method name, different behavior per class
@overrideSignals intentional method override
isRuntime type check (with auto type promotion)
asForce type cast (use carefully)
Abstract classBlueprint that enforces polymorphic contracts

[!TIP] A good rule of thumb: make everything private by default, and only expose what external code truly needs. This minimizes the surface area of your API and makes future refactoring much safer.

In the next lesson, we'll explore Static Members and Enums — class-level data and Dart's powerful enhanced enum feature.

WhatsApp