01What is object-oriented programming?
Object-oriented programming (OOP) is a programming paradigm that organizes software around objects. An object combines state, represented by data, with behavior, represented by operations or methods that can use or modify that data. Programs are designed as cooperating objects and their interactions.
Core idea
In OOP, an object represents a conceptual or real-world entity within a program. Rather than keeping data and the operations on that data entirely separate, OOP groups related state and behavior together.
State and behavior
An object's state describes its current condition, while its behavior defines what the object can do. Methods may read or change the object's state and may communicate with other objects.
- State: Data or attributes held by an object, such as its current values or properties.
- Behavior: Operations or methods that act on the object's state or interact with other objects.
Relationship to procedural programming
Procedural programming generally organizes code around procedures or functions and the sequence of actions they perform. OOP instead emphasizes objects that combine related data and behavior. In practice, many programs can use both object-oriented and procedural techniques.
02What is encapsulation, and why is making every field private not enough by itself?
Encapsulation is an object-oriented design principle that combines an object's state with the operations that manage it, while hiding implementation details behind a controlled public interface. Its purpose is to protect valid state, reduce coupling, and let clients use an object through its behavior rather than its internal representation. Making fields private is a useful mechanism, but it is not sufficient by itself. A class can still expose its internals through unrestricted setters, getters that return mutable objects, or methods that allow invalid state changes. Proper encapsulation requires designing meaningful operations, validating changes, preserving invariants, and avoiding representation leaks.
import java.util.*;
// Bad: private field but exposes mutable list directly
class TeamBad {
private final List<String> members = new ArrayList<>();
public List<String> getMembers() { // leaks internal list
return members;
}
public void addMember(String member) {
members.add(member);
}
}
// Better: expose a read-only view and control mutation
class TeamGood {
private final List<String> members = new ArrayList<>();
public List<String> getMembers() {
return Collections.unmodifiableList(members);
}
public void addMember(String member) {
if (member == null || member.isBlank()) {
throw new IllegalArgumentException("invalid member");
}
members.add(member);
}
public boolean removeMember(String member) {
return members.remove(member);
}
}
// A behavior-oriented API can avoid exposing the collection entirely
class TeamBehavioral {
private final Set<String> members = new LinkedHashSet<>();
public boolean hire(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("invalid member");
}
return members.add(name);
}
public boolean fire(String name) {
return members.remove(name);
}
public boolean isMember(String name) {
return members.contains(name);
}
public int size() {
return members.size();
}
}What encapsulation means
Encapsulation separates what an object does from how it stores or implements that behavior. Clients should rely on the public contract of methods rather than on fields, collection types, or other internal implementation choices.
A well-encapsulated class keeps its state consistent by controlling the operations that read or modify it. This allows the implementation to change without forcing clients to change, provided the public contract remains compatible.
- Hide representation: Keep internal data structures and storage details out of the public contract.
- Expose behavior: Provide operations that express the object’s responsibility and intent.
- Protect invariants: Ensure state changes are validated and cannot leave the object in an invalid state.
Why private fields alone are insufficient
Private visibility prevents code outside the class from directly accessing a field. It does not ensure that the class controls its state meaningfully.
For example, a public setter for every field may allow arbitrary changes without validation or coordination with related fields. Similarly, a getter that returns a mutable internal collection lets callers change the collection without using the class’s intended operations.
- Unrestricted setters: They can permit invalid values or illegal state transitions.
- Mutable representation leaks: Returning an internal mutable array, collection, or mutable object can give callers indirect write access to private state.
- Accessor-based coupling: Getters and setters can make clients depend on storage-oriented details instead of behavior.
How to apply encapsulation
Design the public API around meaningful commands and queries. For example, an operation such as addMember can validate its input and enforce membership rules, whereas exposing a writable members collection cannot reliably do so.
When data must be exposed, avoid giving callers mutable access to internal state. Depending on the language and requirements, return an immutable value, a defensive copy, a read-only view, or an abstraction that does not reveal the underlying representation.
- Validate mutations: Check inputs and preserve invariants within the methods that change state.
- Limit exposure: Expose only the information and operations clients need.
- Avoid mutable leaks: Do not return internal mutable objects when external mutation would bypass the class’s rules.
Private fields versus encapsulation
Private fields are one tool used to support encapsulation. Encapsulation is the broader design goal: a stable, behavior-oriented interface that protects the object’s representation and correctness.
Simple data carriers may intentionally expose data more directly when they have no invariants or behavior to protect. That is a design choice, not the same as strong encapsulation.
03What is the difference between abstraction and encapsulation?
Abstraction presents the essential operations of an object or component while hiding unnecessary implementation details. It focuses on what the component does. Encapsulation bundles state with the operations that manage it and controls access to that state. It focuses on protecting and maintaining the object’s internal state. For example, an interface can abstract a capability such as calculating an area without revealing how each shape performs the calculation. A class can encapsulate its fields by making them private and validating changes through methods. The concepts complement each other: abstraction simplifies how clients use a component, while encapsulation protects its implementation and invariants.
// Abstraction: define a contract for calculating an area.
interface Shape {
double area();
}
// Encapsulation: keep state private and validate updates.
class Circle implements Shape {
private double radius;
public Circle(double radius) {
setRadius(radius);
}
public double getRadius() {
return radius;
}
public void setRadius(double radius) {
if (radius < 0) {
throw new IllegalArgumentException("radius must be non-negative");
}
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
public class Main {
public static void main(String[] args) {
Shape shape = new Circle(2.5);
System.out.println(shape.area());
}
}Abstraction
Abstraction provides a simplified view of a component by exposing a useful contract and omitting details that a client does not need to know. It answers the question, “What can this component do?”
In object-oriented programming, interfaces, abstract classes, and public API contracts are common ways to express abstraction. An abstraction can have multiple implementations without changing the code that depends only on its contract.
- Main goal: Reduce complexity by exposing essential behavior.
- Example: A Shape contract declares area() without specifying how a circle, rectangle, or another shape calculates it.
Encapsulation
Encapsulation groups an object’s data and the operations that work on that data. It also limits direct access to internal representation so the object can control how its state is created and changed.
Access modifiers such as private and protected are language-specific mechanisms commonly used to support encapsulation. Encapsulation does not require getters and setters; behavior-oriented methods are often preferable when they better preserve the object’s rules.
- Main goal: Protect internal state and enforce valid object state.
- Example: A Circle keeps its radius private and validates a new radius before storing it.
How they work together
The two concepts address different concerns and are commonly used together. A client can depend on an abstract contract, while each concrete implementation encapsulates its own state and internal logic.
Encapsulation helps implementations change without exposing internal representation. Abstraction helps clients use a component without depending on a particular implementation.
- Abstraction controls: The operations a client can rely on through a contract.
- Encapsulation controls: Direct access to an object’s internal data and implementation details.
04What is polymorphism?
Polymorphism is an object-oriented programming concept in which code can use objects through a common interface or base type, while each concrete type provides its own behavior for the same operation. For example, code can call processPayment on a PaymentMethod without needing to know whether the actual object is a CreditCard, PayPal, or BankTransfer implementation.
Definition
The word polymorphism means “many forms.” In OOP, it allows different object types to respond to the same operation in type-specific ways. The calling code relies on a shared contract, such as an interface or base class, rather than on a particular concrete class.
How it works
A common interface or base class declares an operation, and concrete classes implement or override that operation. When code invokes the operation through the common type, the behavior associated with the actual object is used.
- Common contract: A type such as PaymentMethod defines processPayment.
- Concrete implementations: Classes such as CreditCard, PayPal, and BankTransfer provide their own payment behavior.
- Caller code: The caller works with PaymentMethod and can use any compatible implementation without changing its logic.
Example
A notification system can define a Notification interface with a sendNotification operation. Email, SMS, and Push implementations can each deliver the message through their respective channel. The caller invokes the same operation regardless of the notification type.
Benefits
Polymorphism reduces coupling between client code and concrete classes. It makes systems easier to extend because a new implementation can often be added by following the existing interface, without modifying code that already depends on that interface.
05What is inheritance, and when does an 'is-a' relationship justify using it?
Inheritance is an object-oriented mechanism in which a derived class extends a base class, forming a subtype relationship. It is appropriate when the derived type is genuinely a specialized form of the base type and can be used wherever the base type is expected without violating its contract. This is the practical meaning of an “is-a” relationship and supports polymorphism. Use inheritance to model a stable shared abstraction, not merely to reuse implementation; for “has-a” or “uses-a” relationships, composition is usually a better fit.
// Correct "is-a" example
class Animal {
void eat() {
System.out.println("eating");
}
}
class Bird extends Animal {
void fly() {
System.out.println("flying");
}
}
// Potential LSP violation when Rectangle allows independent dimensions
class Rectangle {
protected int width;
protected int height;
void setWidth(int width) {
this.width = width;
}
void setHeight(int height) {
this.height = height;
}
int area() {
return width * height;
}
}
class Square extends Rectangle {
@Override
void setWidth(int width) {
this.width = width;
this.height = width;
}
@Override
void setHeight(int height) {
this.width = height;
this.height = height;
}
}
// Composition for a "has-a" relationship
class Engine {
void start() {
System.out.println("engine started");
}
}
class Car {
private final Engine engine;
Car(Engine engine) {
this.engine = engine;
}
void start() {
engine.start();
}
}What inheritance means
Inheritance lets a derived class reuse and extend the behavior and accessible members of a base class. It also establishes a type relationship: code written against the base type can work with compatible derived objects.
The exact inheritance rules, such as member visibility, multiple inheritance support, and method-overriding behavior, depend on the programming language.
When “is-a” justifies inheritance
An “is-a” statement is a useful starting point, but it is not sufficient by itself. The derived class must preserve the promises made by the base class so that replacing a base object with a derived object does not break correct client code.
This requirement is commonly expressed by the Liskov Substitution Principle: a subtype should honor the behavioral contract of its supertype.
- Specialization: The derived type represents a more specific kind of the base type, such as a Bird being an Animal.
- Substitutability: A derived object can be passed to code expecting the base type without surprising changes in expected behavior.
- Shared abstraction: The base class defines meaningful common behavior or a common interface that clients need to use polymorphically.
- Stable semantics: The hierarchy reflects the domain model rather than being created only to avoid duplicated code.
When to prefer composition
Prefer composition when one object contains, uses, or delegates work to another object. Composition keeps responsibilities separate and avoids forcing a subtype relationship where none exists.
Inheritance can couple a derived class to assumptions and changes in the base class. If the goal is only implementation reuse, delegation to a contained object is often more flexible.
- Has-a relationship: A Car has an Engine, so the Car should contain an Engine rather than extend it.
- Behavior reuse only: If two classes need the same helper behavior but are not substitutable types, extract that behavior into a separate component.
- Contract mismatch: Avoid inheritance when overriding a base operation would require changing its expected meaning or restricting valid base-class behavior.
A common contract mismatch
A Square may satisfy the everyday “is-a Rectangle” statement, but it can violate a mutable Rectangle contract if Rectangle allows width and height to change independently. Making Square setters update both dimensions can surprise code that expects those dimensions to remain independent.
The issue is not the names alone; it is whether the base-class contract was designed so that Square remains substitutable.
06What is the difference between method overloading and method overriding?
Method overloading defines multiple methods with the same name but different parameter lists. The compiler selects the appropriate overload from the method call's arguments, so it is commonly called compile-time polymorphism. Method overriding occurs when a subclass provides its own implementation of an inherited instance method with the same signature; the implementation is selected at runtime based on the actual object, enabling runtime polymorphism.
class Calculator {
// Overloaded methods: same name, different parameter lists
int add(int a, int b) {
return a + b;
}
double add(double a, double b) {
return a + b;
}
int add(int a, int b, int c) {
return a + b + c;
}
}
class Animal {
String speak() {
return "generic sound";
}
}
class Dog extends Animal {
@Override
String speak() {
return "bark";
}
}
public class Demo {
public static void main(String[] args) {
Calculator c = new Calculator();
System.out.println(c.add(1, 2)); // calls add(int, int)
System.out.println(c.add(1.5, 2.5)); // calls add(double, double)
Animal a = new Dog();
System.out.println(a.speak()); // calls Dog.speak() at runtime
}
}Method overloading
Overloading uses the same method name with different parameter lists. The parameter lists must differ by number, types, or order of parameters; changing only the return type does not create a valid overload in Java.
It does not require inheritance, although methods with the same name and different parameter lists may also occur across a class hierarchy. Overload resolution is performed at compile time using the declared types of the arguments.
Method overriding
Overriding occurs when a subclass redefines an inherited instance method with the same name and parameter types. In Java, the overriding method must have the same return type or a covariant return type.
When a method is overridden, a call through a superclass reference is dynamically dispatched to the implementation for the actual object at runtime.
Rules that depend on the language
The following rules are stated for Java. An overriding method cannot reduce the visibility of the inherited method. A final method cannot be overridden. Static methods are hidden rather than overridden, and private methods are not inherited, so they cannot be overridden.
Java also restricts checked exceptions declared by an overriding method: it cannot declare broader checked exceptions than the inherited method. These restrictions do not arise merely from overloading.
When to use each
Use overloading to offer related operations with different input forms, such as accepting two integers, two floating-point values, or three integers. Use overriding when a subtype needs behavior specific to that subtype while preserving the superclass method contract.
07What is the difference between compile-time polymorphism and runtime polymorphism?
Compile-time polymorphism is resolved by the compiler using information available at the call site, such as argument types and number of arguments. Common examples are method/function overloading and operator overloading. Runtime polymorphism is resolved while the program runs: a call through a base-class or interface reference invokes the implementation for the object’s actual runtime type, typically through method overriding and dynamic dispatch.
class PolymorphismDemo {
static class Calculator {
int add(int a, int b) {
return a + b;
}
double add(double a, double b) {
return a + b;
}
}
static class Animal {
void speak() {
System.out.println("Animal speaks");
}
}
static class Dog extends Animal {
@Override
void speak() {
System.out.println("Dog barks");
}
}
public static void main(String[] args) {
Calculator calculator = new Calculator();
System.out.println(calculator.add(1, 2));
System.out.println(calculator.add(1.0, 2.0));
Animal animal = new Dog();
animal.speak();
}
}#include <iostream>
void func(int x) {
std::cout << "int: " << x << '
';
}
void func(double x) {
std::cout << "double: " << x << '
';
}
struct Base {
virtual void speak() {
std::cout << "Base
";
}
virtual ~Base() = default;
};
struct Derived : Base {
void speak() override {
std::cout << "Derived
";
}
};
int main() {
func(10);
func(3.14);
Base* base = new Derived();
base->speak();
delete base;
}Compile-time polymorphism
Compile-time polymorphism, also called static polymorphism, selects the target operation before the program executes. For example, when overloaded methods have different parameter lists, the compiler chooses the applicable overload from the argument types at the call site.
The selected overload is fixed in the generated program for that call. It does not require inheritance. Templates in C++ are another form of compile-time polymorphism; generic mechanisms in other languages can have different implementation and dispatch behavior.
- Typical mechanisms: Function or method overloading, operator overloading, and C++ templates.
- Selection basis: Compile-time information, such as declared argument types, argument count, and available overloads.
Runtime polymorphism
Runtime polymorphism, also called dynamic polymorphism, selects an overridden implementation according to the actual object at runtime. It allows code written against a common base class or interface to work with multiple concrete implementations.
For example, if an Animal reference refers to a Dog object, calling speak() invokes Dog.speak() when the method participates in dynamic dispatch. The language runtime may implement this with a dispatch table, but that is an implementation detail rather than a universal requirement.
- Typical mechanisms: Method overriding through virtual methods, abstract methods, or interface methods, depending on the language.
- Requirement: A common polymorphic type relationship, such as a base class or interface; the exact language mechanism varies.
Trade-offs
Compile-time polymorphism is useful when the operation can be selected from static type information. It often enables direct calls and compiler optimization, although optimization results depend on the compiler and build settings.
Runtime polymorphism is useful when behavior must vary with the concrete object type, such as when processing different implementations through one interface. Dynamic dispatch can add indirection, though modern compilers and runtimes may optimize some calls when the target is known.
- Use compile-time polymorphism: When the desired operation is determined by the call’s declared types and signatures.
- Use runtime polymorphism: When clients should depend on a common abstraction while concrete implementations can vary at runtime.
08What is the difference between an abstract class and an interface, and when would you choose each?
An abstract class is a base class that can combine shared state, constructors, implemented methods, and abstract methods. An interface defines a contract or capability that classes can implement, often alongside another base class. Choose an abstract class when related subclasses need common implementation or state. Choose an interface when different types should expose the same behavior, especially when a class needs to adopt multiple contracts. The exact rules depend on the language; for example, Java interfaces can include default, static, and private methods, but they do not provide per-object instance fields or constructors.
// Abstract class with state and shared implementation
abstract class Vehicle {
protected int speed;
protected Vehicle(int initialSpeed) {
this.speed = initialSpeed;
}
public void brake() {
speed = Math.max(0, speed - 10);
}
public abstract void accelerate();
}
// Interface representing a capability
interface Flyable {
default void takeOff() {
System.out.println("Taking off");
}
static void checkWeather() {
System.out.println("Checking weather");
}
}
// Plane inherits shared Vehicle behavior and implements Flyable.
class Plane extends Vehicle implements Flyable {
public Plane(int initialSpeed) {
super(initialSpeed);
}
@Override
public void accelerate() {
speed += 50;
}
}
class Main {
public static void main(String[] args) {
Plane plane = new Plane(0);
plane.accelerate();
plane.takeOff();
Flyable.checkWeather();
}
}Abstract class
An abstract class is an incomplete class intended to be extended. It can contain instance fields, constructors, concrete methods, and abstract methods. Subclasses inherit its shared implementation and can supply the missing behavior.
An abstract class is useful when subclasses belong to the same conceptual family and need common state or a common implementation path. In Java, a class can extend only one class, abstract or concrete.
Interface
An interface defines a contract: a type that implements it agrees to provide its required behavior. It is commonly used to model a capability or role that can apply to otherwise unrelated classes.
In Java, interfaces can declare abstract methods and can also contain default, static, and private methods. Default methods can provide reusable behavior, but an interface does not have constructors or per-instance fields.
When to choose each
Use an abstract class when derived classes must share state, initialization through a constructor, protected helper methods, or substantial common implementation. It is also appropriate when the base class defines part of an algorithm and subclasses fill in selected steps.
Use an interface when the main goal is to define an API or capability that many types can adopt. A class can implement multiple interfaces in Java, so interfaces are appropriate when a type must satisfy several independent contracts.
- Abstract class: Choose it for a shared base implementation and state among closely related subclasses.
- Interface: Choose it for a role, capability, or contract that may be implemented by unrelated classes.
Design considerations
Adding or changing behavior in a base class can affect all subclasses, so a base hierarchy should remain focused and stable. Interfaces reduce coupling to a particular implementation, although default methods can still create conflicts when a class inherits incompatible defaults; the class must resolve such conflicts explicitly.
These distinctions are language-specific in detail. The comparison and example below use Java rules.
09Why is composition often preferred over inheritance?
Give an example.
10What happens when a base-type reference refers to a derived object and an overridden method is called?
11What is the difference between object identity and value equality?
Object identity asks whether two references denote the very same object. Value equality asks whether two objects are considered equal by their values or logical state, even when they are separate instances. For example, in Python, `is` checks identity and `==` checks equality; in Java, `==` compares object references while `equals()` compares values when the type implements it accordingly.
a = [1, 2]
b = a # Same object
c = [1, 2] # Different object with equal contents
print(a is b) # True: same identity
print(a == b) # True: equal values
print(a is c) # False: different identities
print(a == c) # True: equal values
b.append(3)
print(a) # [1, 2, 3], because a and b refer to the same object
print(c) # [1, 2]import java.util.Arrays;
import java.util.List;
String s1 = new String("x");
String s2 = new String("x");
System.out.println(s1 == s2); // false: different object references
System.out.println(s1.equals(s2)); // true: same character content
List<String> list = Arrays.asList("a");
System.out.println(list.contains(new String("a"))); // true: uses equals()Object identity
An object has an identity that distinguishes it from every other object instance. Two references have the same identity only when they refer to that exact instance.
When two references denote the same mutable object, a change made through one reference is observable through the other because both access the same object.
Value equality
Value equality means that two objects represent the same value or equivalent state. They may be different instances stored independently.
The precise meaning of equality is defined by the language and the type. For user-defined types, an equality operation should normally be reflexive, symmetric, transitive, and consistent.
Language-specific expressions
The general distinction is universal, but the syntax used to test it depends on the language.
- Java: `==` compares object references, while `equals()` is used for logical equality.
- Python: `is` checks identity, while `==` invokes value-equality behavior such as `__eq__`.
- C#: `ReferenceEquals(a, b)` checks whether two references denote the same object; `Equals` and some overloaded `==` operators can express value equality.
- C++: Pointer comparison can test whether two pointers refer to the same object, while `operator==` can define value equality for an object type.
Practical use and collection behavior
Use identity when the exact instance matters, such as checking a sentinel or determining whether two references share mutable state. Use value equality when the question is whether two values are logically equivalent.
For hash-based collections, equality and hashing must agree. In Java, types that override `equals()` should provide a compatible `hashCode()`. In Python, hashable objects used as dictionary keys or set elements must follow the language's equality and hashing rules.
12What is the purpose of a constructor, and why should an object be valid after construction?
A constructor establishes an object’s initial valid state. It initializes required fields, validates inputs, and enforces the class invariants—the conditions that must hold for the object to behave correctly. After successful construction, the object should be ready to use so callers and instance methods do not need to handle an uninitialized or internally inconsistent state. If that state cannot be established, construction should report failure using the language-appropriate mechanism, such as an exception or a factory method that returns an error result.
#include <cstdio>
#include <stdexcept>
#include <string>
class FileWrapper {
public:
explicit FileWrapper(const std::string& path) {
file_ = std::fopen(path.c_str(), "r");
if (!file_) {
throw std::runtime_error("cannot open file");
}
// Invariant: file_ != nullptr.
}
~FileWrapper() {
if (file_) {
std::fclose(file_);
}
}
FileWrapper(const FileWrapper&) = delete;
FileWrapper& operator=(const FileWrapper&) = delete;
FILE* get() const { return file_; }
private:
FILE* file_ = nullptr;
};
// Construction either succeeds with a valid FileWrapper or throws.public class Person {
private final String name;
private final int age;
public Person(String name, int age) {
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("name required");
}
if (age < 0) {
throw new IllegalArgumentException("age must be non-negative");
}
this.name = name;
this.age = age;
}
}class Vector:
def __init__(self, coords):
if not coords:
raise ValueError("coords must not be empty")
self.coords = tuple(coords)
# Vector([1, 2, 3]) creates a valid object.
# Vector([]) raises ValueError.Purpose of a constructor
A constructor runs when an object is created. Its main responsibility is to establish the minimum state required by the class contract: initialize essential data, validate constructor arguments, and set up required dependencies or resources.
This is where class invariants should be established. For example, if a class requires a non-empty name or a non-negative age, the constructor can reject invalid input rather than allowing an object that violates those rules.
- Initialize state: Assign required fields and dependencies.
- Validate input: Reject values that would violate the class contract.
- Establish invariants: Ensure the conditions required for correct object behavior hold after successful construction.
Why the object must be valid after construction
A successfully constructed object should be immediately usable according to its public contract. This lets callers use it without first checking whether an initialization step was missed, and it lets instance methods rely on the object’s invariants.
Allowing a publicly accessible object to remain partially initialized increases complexity and creates more opportunities for incorrect use. Keeping invalid states from escaping construction improves encapsulation and makes the class easier to reason about and test.
- Predictable behavior: Clients can assume that successful construction produces a usable object.
- Simpler methods: Methods can rely on established invariants instead of repeatedly checking initialization state.
- Stronger encapsulation: The class controls and protects its own validity rules.
Failure during construction
If a constructor cannot establish a valid state, it should not silently leave a usable reference to an invalid object. In languages with exceptions, a constructor can throw or raise an exception. Where explicit error handling is preferred, a factory method can perform the work and return either a valid object or an error result.
Resources acquired during construction require careful cleanup if a later step fails. The exact mechanism is language-dependent: C++ commonly relies on RAII and automatic destruction of fully constructed members, while other languages use structured cleanup or resource-management constructs.
Two-phase initialization—creating an object and requiring a later init call—should generally be avoided when it exposes an object before it is ready. It may be necessary in some APIs, but the not-ready state must then be made explicit and controlled.
- Exception-based failure: Throw or raise when the required valid state cannot be created.
- Factory-based failure: Use a factory that returns an object only on success and otherwise returns an explicit error result.
- Resource safety: Ensure resources acquired before failure are released by the language-appropriate cleanup mechanism.
13How do access modifiers support encapsulation and data hiding?
Page 2
14What is the difference between association, aggregation, and composition?
Association is a general relationship in which objects are connected or interact, without implying ownership. Aggregation and composition are whole–part forms of association. In aggregation, parts can exist independently of the whole and may be shared. In composition, the whole exclusively owns its parts in the model, and a part’s lifetime is tied to that whole.
class Course {
private final String title;
Course(String title) {
this.title = title;
}
String getTitle() {
return title;
}
}
class Student {
private final String name;
Student(String name) {
this.name = name;
}
void enroll(Course course) {
System.out.println(name + " enrolls in " + course.getTitle());
}
}import java.util.List;
class Player {
private final String name;
Player(String name) {
this.name = name;
}
}
class Team {
private final List<Player> players;
Team(List<Player> players) {
this.players = players;
}
}
// Player objects are supplied externally and can exist without a Team.import java.util.ArrayList;
import java.util.List;
class House {
private static class Room {
private final String name;
Room(String name) {
this.name = name;
}
}
private final List<Room> rooms = new ArrayList<>();
House(int count) {
for (int i = 0; i < count; i++) {
rooms.add(new Room("Room" + i));
}
}
}
// Room is an internal part of House in this model.Association
Association represents a general structural relationship between classes or objects. For example, a Student may be associated with a Course. Neither object necessarily owns or controls the lifecycle of the other.
A temporary use of an object, such as passing it to a method, is often modeled as a dependency rather than a persistent association in UML. In ordinary code, both may be implemented with references, so the intended domain relationship matters more than the syntax.
Aggregation
Aggregation is a whole–part relationship with independent lifecycles. The whole groups or refers to parts, but the parts can continue to exist if the whole is removed and can potentially be shared by other wholes.
For example, a Team aggregates Player objects when players exist independently of a particular team. Aggregation does not imply that the whole is responsible for creating or destroying its parts.
Composition
Composition is the strongest whole–part relationship. A part belongs to one composite at a time in the model, and its lifecycle is dependent on that composite. When the composite is destroyed, its parts are conceptually destroyed as well.
Composition is a design relationship, not merely a coding pattern. Creating a member object inside a constructor can support composition, but it does not by itself establish the relationship; the model must also treat that object as an exclusive, lifecycle-bound part.
Practical interpretation
Use association when objects simply collaborate. Use aggregation when the whole groups independent parts. Use composition when a part has no meaningful independent lifecycle outside its whole.
The exact implementation of ownership and cleanup depends on the language and runtime. For example, garbage-collected languages reclaim memory automatically, but the composition relationship still expresses lifecycle and exclusive ownership in the object model.
- UML notation: Association is typically shown with a plain line, aggregation with a hollow diamond at the whole end, and composition with a filled diamond at the whole end.
15What is the difference between shallow copy and deep copy?
A shallow copy creates a new outer object but keeps references to the same nested objects. A deep copy creates a new object graph for the mutable nested objects, so changes made through the copy do not affect the original. The exact behavior depends on the language and copying mechanism; immutable values may safely be shared even in a deep copy.
import copy
orig = [1, [2, 3]]
sh = copy.copy(orig)
dp = copy.deepcopy(orig)
# Replacing a top-level element affects only the shallow copy.
sh[0] = 10
# Mutating a nested list affects orig because it is shared with sh.
sh[1][0] = 20
print('orig after shallow changes:', orig)
print('sh:', sh)
print('dp (unchanged):', dp)
# Mutating a nested list in the deep copy does not affect orig.
dp[1][1] = 99
print('orig after deep change:', orig)
print('dp:', dp)class Node implements Cloneable {
int value;
Node child;
Node(int value, Node child) {
this.value = value;
this.child = child;
}
// Shallow clone: referenced objects are not cloned.
@Override
public Node clone() throws CloneNotSupportedException {
return (Node) super.clone();
}
// Recursive deep copy for an acyclic Node structure.
Node deepCopy() {
return new Node(value, child == null ? null : child.deepCopy());
}
}
Node original = new Node(1, new Node(2, null));
Node shallow = original.clone();
shallow.child.value = 99;
System.out.println(original.child.value); // 99: child is shared
Node deep = original.deepCopy();
deep.child.value = 5;
System.out.println(original.child.value); // 99: original is unchangedShallow copy
A shallow copy duplicates only the outer container or object. Its fields or elements that refer to other objects still refer to the same nested objects as the original.
Therefore, changing a top-level field or element in the shallow copy does not change the original, but mutating a shared nested object is visible through both references.
Deep copy
A deep copy duplicates the outer object and recursively copies relevant nested mutable objects. This gives the copy independent mutable state, so a mutation in the copied structure does not affect the corresponding structure in the original.
Deep-copy implementations commonly preserve relationships within the copied graph. For example, they may need to track already copied objects to handle cyclic references without recursing indefinitely.
Language and implementation considerations
Copy behavior is language- and API-specific. A language may provide separate shallow- and deep-copy utilities, or it may require the programmer to define how referenced objects are copied.
Immutable objects can usually be shared safely because they cannot be modified. Objects representing external resources or runtime-specific state may not have a meaningful general-purpose deep-copy operation.
- Python: copy.copy() performs a shallow copy, while copy.deepcopy() performs a recursive copy and uses memoization to handle repeated references and cycles.
- Java: Object.clone(), when implemented using super.clone(), copies fields and is shallow for referenced objects. A deep copy requires explicit copying of referenced mutable objects.
- C++: Copy constructors and copy-assignment operators define copy behavior. Classes that own resources must implement appropriate copy semantics when independent ownership is required.
Choosing between them
Use a shallow copy when sharing nested data is intentional or when that data is immutable. Use a deep copy when the new structure must be independently mutable.
Deep copying generally requires more time and memory because it traverses and allocates objects throughout the structure.
16What is an immutable object, and what advantages can immutability provide?
An immutable object is an object whose observable state cannot change after it is created. Instead of modifying the existing object, an operation that represents a change returns a new object. Immutability makes objects safer to share, easier to reason about, and suitable for stable equality and hash-based collections when their equality-relevant state is immutable.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
public final class Person {
private final String name;
private final List<String> tags;
public Person(String name, List<String> tags) {
this.name = Objects.requireNonNull(name);
this.tags = Collections.unmodifiableList(
new ArrayList<>(Objects.requireNonNull(tags))
);
}
public String getName() {
return name;
}
public List<String> getTags() {
return tags;
}
public Person withName(String newName) {
return new Person(newName, tags);
}
public Person withAddedTag(String tag) {
List<String> newTags = new ArrayList<>(tags);
newTags.add(tag);
return new Person(name, newTags);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person)) return false;
Person other = (Person) o;
return name.equals(other.name) && tags.equals(other.tags);
}
@Override
public int hashCode() {
return Objects.hash(name, tags);
}
}Definition
An object is immutable when its externally observable state remains unchanged throughout its lifetime. Its fields may reference other objects, but those referenced objects must not allow the immutable object's observable state to be changed.
Advantages
Because an immutable object's state does not change, it can be shared without callers needing to coordinate updates. This reduces unintended side effects and makes code easier to understand, test, and use in concurrent programs.
- Safer sharing: Immutable objects can be shared between threads without synchronization for changes to that object's state.
- Simpler reasoning: A reference always represents the same value, which reduces bugs caused by unexpected mutation.
- Stable map and set usage: An immutable object whose equals and hashCode depend only on immutable state is safe to use as a key in hash-based collections.
- Caching and reuse: Instances can often be safely reused because their value does not change.
- Functional style: Operations can return new values rather than modify existing ones, which supports composition and predictable behavior.
Implementing immutability
The exact mechanisms depend on the programming language. In languages such as Java, the class must prevent both direct field changes and indirect changes through mutable objects supplied by callers.
- Prevent mutation: Do not expose setters or other methods that alter the object's state; return a new instance for value-changing operations.
- Protect fields: Keep state private and initialize it during construction. In Java, fields are commonly declared final to prevent reassignment.
- Defend mutable state: Copy mutable constructor inputs and avoid exposing mutable internal objects directly. Return copies or immutable representations when necessary.
- Control inheritance: In Java, making the class final is a common way to prevent subclasses from weakening the immutability contract.
Trade-offs
Creating a new object for each logical change can increase allocation and copying costs, especially for large objects or frequent updates. Builders and data structures that share unchanged internal structure can reduce these costs when appropriate.
17What do high cohesion and low coupling mean in object-oriented design?
High cohesion means a class or module has closely related responsibilities that serve a focused purpose. Low coupling means classes or modules have minimal, well-defined dependencies on one another. Together, they make a system easier to understand, test, change, and reuse because changes are more likely to stay within the relevant module.
High cohesion
Cohesion describes how strongly the responsibilities within one class or module belong together. A highly cohesive class groups related data and behavior around a clear responsibility.
A cohesive class is not defined only by being small; its operations should support the same purpose. For example, a repository can focus on storing and retrieving a particular kind of data rather than also handling user-interface formatting or unrelated business rules.
Low coupling
Coupling describes the degree of dependency between classes or modules. Low coupling means a unit relies on other units through limited, clear contracts instead of their internal implementation details.
Low coupling does not mean that modules never interact. It means their necessary interactions are controlled, so a change in one module is less likely to require changes throughout the system.
Why they matter
High cohesion makes a unit easier to understand because its code addresses a focused concern. Low coupling reduces the ripple effect of changes because fewer units depend directly on one another.
Used together, these properties improve maintainability, testability, and reuse. A focused module with a narrow, stable interface can be tested or replaced more independently.
Design practices
Define meaningful class boundaries, keep related behavior and data together, and avoid mixing unrelated concerns in one class. Expose only the operations that clients need, while keeping implementation details encapsulated.
- Improve cohesion: Split a class when it contains unrelated responsibilities, such as persistence, presentation formatting, and business-rule processing.
- Reduce coupling: Depend on clear interfaces or abstractions where appropriate, and pass required dependencies explicitly rather than relying on global state.
- Watch for warning signs: Unrelated methods in one class can indicate low cohesion; frequent changes across several modules for one feature can indicate excessive coupling.
18What is the Single Responsibility Principle, and how can you identify a class with too many responsibilities?
The Single Responsibility Principle (SRP) states that a class should have one responsibility: one cohesive purpose and one primary reason to change. A class has too many responsibilities when it combines unrelated concerns, such as business rules, persistence, notification, and formatting, so that changes in any of those areas require modifying the same class. Typical signs are low cohesion, methods and fields that serve unrelated purposes, dependencies on unrelated collaborators, a vague or overly broad class name, and change history showing that different kinds of changes repeatedly affect the class. Class size alone is not proof of an SRP violation; the key question is whether its members support the same responsibility and change together for the same reason.
/* Before: UserManager combines persistence and notification concerns. */
public class UserManager {
private final Database db;
private final EmailClient emailClient;
public UserManager(Database db, EmailClient emailClient) {
this.db = db;
this.emailClient = emailClient;
}
public void createUser(User user) {
db.insert("users", user.toMap());
emailClient.send(user.getEmail(), "Welcome", "Thanks for signing up!");
}
public void deleteUser(String userId) {
db.delete("users", userId);
emailClient.sendAdminNotification("User deleted: " + userId);
}
}
/* After: persistence and notification are separate responsibilities. */
public class UserRepository {
private final Database db;
public UserRepository(Database db) {
this.db = db;
}
public void save(User user) {
db.insert("users", user.toMap());
}
public void delete(String userId) {
db.delete("users", userId);
}
}
public class EmailService {
private final EmailClient emailClient;
public EmailService(EmailClient emailClient) {
this.emailClient = emailClient;
}
public void sendWelcome(String to) {
emailClient.send(to, "Welcome", "Thanks for signing up!");
}
public void notifyAdmin(String message) {
emailClient.sendAdminNotification(message);
}
}
/* This class coordinates the use case without implementing persistence or email details. */
public class UserManager {
private final UserRepository userRepository;
private final EmailService emailService;
public UserManager(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
public void createUser(User user) {
userRepository.save(user);
emailService.sendWelcome(user.getEmail());
}
public void deleteUser(String userId) {
userRepository.delete(userId);
emailService.notifyAdmin("User deleted: " + userId);
}
}Meaning of SRP
SRP is one of the SOLID principles. It is commonly expressed as: a class should have one reason to change. More precisely, its methods and data should form one cohesive responsibility, often associated with a single kind of concern or stakeholder-driven change.
SRP does not mean every class must contain only one method or be small. A class can contain several related operations when they support the same abstraction and tend to change together.
How to identify too many responsibilities
Look at the class's behavior, dependencies, tests, and change history. The strongest evidence is that independent changes in different concerns require edits to the same class.
- Mixed concerns: The class performs unrelated work, such as storing data, applying business rules, sending notifications, and formatting output.
- Low cohesion: Its methods and fields naturally fall into separate groups, with little shared data or purpose.
- Unrelated dependencies: It depends on collaborators from distinct concerns, such as a repository, mail client, and formatter, without one cohesive role explaining those dependencies.
- Multiple reasons to change: Database changes, notification-policy changes, and presentation changes each require modifying the same class.
- Broad naming: The class name is vague or does not accurately describe all of its behavior.
- Difficult focused testing: Tests require many unrelated collaborators or mocks because the class coordinates several independent concerns.
Refactoring an SRP violation
First, identify groups of methods and data that change for different reasons. Extract those groups into focused classes, then let a higher-level coordinator compose them when an operation legitimately requires several responsibilities.
Refactor incrementally and preserve behavior with tests. Interfaces can be useful at boundaries where callers need to depend on an abstraction, but they are not required merely because a class is extracted.
- Separate cohesive groups: Move persistence, notification, formatting, or other independent concerns into classes with clear, focused APIs.
- Keep orchestration explicit: A coordinating class may call the focused components to complete a use case without taking ownership of their internal responsibilities.
- Avoid over-splitting: Do not create separate classes for operations that are naturally part of the same cohesive abstraction.
Benefits and trade-offs
Applying SRP usually improves maintainability, readability, and testability because each class has a clearer purpose and smaller change surface. It can also reduce coupling between unrelated concerns.
However, splitting classes introduces more types and collaboration paths. Apply SRP to improve cohesion and isolate independent change, not simply to minimize the number of methods in each class.
19How does the Open/Closed Principle help a system support new behaviour without changing existing client logic?
The Open/Closed Principle helps by making client code depend on a stable abstraction rather than on specific implementations. To introduce new behaviour, add a new implementation, strategy, or decorator that satisfies the existing contract, then supply it through configuration, a factory, or dependency injection. The client continues to call the same abstraction, so its business logic does not need to change.
interface PaymentStrategy {
void pay(double amount);
}
class CreditCard implements PaymentStrategy {
public void pay(double amount) {
System.out.println("Paid " + amount + " with Credit Card");
}
}
class PayPal implements PaymentStrategy {
public void pay(double amount) {
System.out.println("Paid " + amount + " with PayPal");
}
}
// Client depends only on the abstraction.
class Checkout {
private final PaymentStrategy strategy;
Checkout(PaymentStrategy strategy) {
this.strategy = strategy;
}
void processOrder(double amount) {
strategy.pay(amount);
}
}
// New behavior added without modifying Checkout.
class Bitcoin implements PaymentStrategy {
public void pay(double amount) {
System.out.println("Paid " + amount + " with Bitcoin");
}
}
public class App {
public static void main(String[] args) {
// Composition chooses the behavior supplied to the client.
PaymentStrategy strategy = new Bitcoin();
Checkout checkout = new Checkout(strategy);
checkout.processOrder(250.0);
}
}Core idea
The Open/Closed Principle states that software entities should be open for extension and closed for modification. In practice, this means designing a stable extension point so that new behaviour can be introduced by adding code rather than repeatedly editing established client logic.
A module is not necessarily closed to every possible change. It is closed with respect to the variations anticipated by its abstraction or extension mechanism.
How the client remains unchanged
A client depends on an interface or abstract type that defines the operations it needs. It does not need to know which concrete class performs those operations.
When a new implementation conforms to the same contract, it can be substituted for an existing implementation. The client invokes the same methods, while the selected implementation provides different runtime behaviour.
- Abstraction: Defines the stable contract used by the client.
- New implementation: Adds behaviour by implementing the existing contract.
- Composition: Selects and supplies the implementation outside the client, for example through a factory or dependency injection.
Example
In the example, Checkout depends on PaymentStrategy. Adding Bitcoin creates another PaymentStrategy implementation, so Checkout does not need to be modified. The application composition code chooses whether Checkout receives CreditCard, PayPal, or Bitcoin.
Design considerations
OCP reduces the chance that a new variation will disturb stable client code, but it does not eliminate all changes. The composition layer may need to select or register the new implementation, and the abstraction itself may need revision if the new requirement cannot be expressed by the existing contract.
Abstractions should represent meaningful, likely points of variation. Creating interfaces or extension mechanisms for every small possibility can add unnecessary indirection and complexity.
20What is dynamic binding, and how does it enable runtime polymorphism?
Dynamic binding, or late binding, is the runtime selection of the method implementation to execute based on an object’s actual type rather than the reference variable’s declared type. It enables runtime polymorphism because a base-class or interface reference can refer to different concrete objects, and an overridden method call invokes the appropriate implementation for the object currently referenced.
class Animal {
void speak() {
System.out.println("Animal speaks");
}
}
class Dog extends Animal {
@Override
void speak() {
System.out.println("Woof");
}
}
class Cat extends Animal {
@Override
void speak() {
System.out.println("Meow");
}
}
public class Main {
public static void main(String[] args) {
Animal a1 = new Dog();
Animal a2 = new Cat();
a1.speak(); // Prints "Woof"
a2.speak(); // Prints "Meow"
Animal[] zoo = { new Dog(), new Animal(), new Cat() };
for (Animal a : zoo) {
a.speak();
}
}
}Definition
In object-oriented programming, dynamic binding determines the target of an overridable method call while the program is running. The compiler verifies that the method can be called through the declared reference type, while runtime dispatch selects the implementation associated with the actual object type.
Dynamic binding contrasts with static binding, in which the method target is determined at compile time.
How it enables runtime polymorphism
A reference declared as a base class or interface can hold objects of multiple implementing or derived types. When code calls an overridden method through that reference, the same call site can produce different behavior for different objects.
This lets client code depend on a common abstraction instead of concrete classes. New implementations can be used by the same client code as long as they conform to the required base class or interface.
- Declared type: Determines which members may be called through the reference.
- Actual object type: Determines which overridden implementation runs for a dynamically dispatched call.
Runtime dispatch
Languages use runtime dispatch mechanisms to locate the correct overridden method. The exact mechanism is language- and implementation-specific; for example, many compiled object-oriented language implementations use method tables, while dynamic languages may use runtime method lookup.
In Java, ordinary instance-method calls are generally dynamically bound, except for methods such as static, final, and private methods, which are not overridden in the same way.
Trade-offs
Dynamic binding improves extensibility and loose coupling because code can operate on abstractions while object-specific behavior remains in concrete classes. It can introduce dispatch overhead, although runtime systems may optimize many calls when the target can be determined safely.
21What is the difference between static members and instance members, and when would you use each?
Static members belong to the class or type and are shared by all its objects. Instance members belong to individual objects, so each object has its own instance fields and can use methods that operate on its own state. Use static members for class-wide constants, counters, or utility behavior; use instance members for data and behavior that vary from one object to another.
public class Widget {
// Static: shared by all Widget objects
private static int totalCreated = 0;
public static final double VERSION = 1.0;
// Instance: each Widget has its own values
private final int id;
private String name;
public Widget(String name) {
this.id = ++totalCreated;
this.name = name;
}
// Static method: class-level behavior
public static int getTotalCreated() {
return totalCreated;
}
// Instance method: operates on this object's state
public String describe() {
return "Widget{" + "id=" + id + ", name='" + name + "'}";
}
public static synchronized void resetTotalCreated() {
totalCreated = 0;
}
public static void main(String[] args) {
Widget a = new Widget("A");
Widget b = new Widget("B");
System.out.println(Widget.getTotalCreated()); // 2
System.out.println(a.describe()); // a's instance data
}
}Core difference
A static field has one value associated with the class, rather than a separate value for every object. A static method is called on the class and does not have a this object, so it can directly access only static members.
An instance field is stored separately in each object. An instance method runs for a particular object, can access that object's instance and static members, and can use the object's state.
Access and lifecycle
Static members are normally accessed through the class name, such as Widget.getTotalCreated(). Instance members are accessed through an object reference, such as widget.describe().
In Java, static fields are initialized when their class is initialized, and instance fields are initialized as part of creating an object. Precise storage layout and lifetime details depend on the language runtime and implementation.
When to use each
Choose static when data or behavior is genuinely shared across all objects, or when an operation does not need object-specific state. Choose instance members when the state or behavior belongs to a particular object.
- Static members: Class-wide constants, shared counters, shared caches, factory methods, and stateless utility methods.
- Instance members: Properties that differ per object, such as an object's identifier or name, and behavior that depends on that object's state.
- Mutable shared state: Use static mutable fields carefully because they are shared. If multiple threads can access them, coordinate access with appropriate synchronization or concurrency mechanisms.
Inheritance and polymorphism
Instance methods can participate in runtime polymorphism when they are overridable: a call can dispatch to an implementation based on the object's runtime type. In Java, static methods are associated with the class and are hidden rather than overridden, so they do not use instance-method dynamic dispatch.
22A payment application supports Card, UPI, and Wallet payments. How would you design it so new payment methods can be added easily?
Design the payment module around a common PaymentProvider interface and a PaymentService that delegates to the appropriate provider through a registry or factory. Card, UPI, and Wallet are separate implementations of the same contract. To add a new method, implement the interface, register it under a method key, and provide any adapter needed for an external gateway SDK. The orchestration code remains unchanged, following the Open/Closed Principle.
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
public final class PaymentRequest {
public final String idempotencyKey;
public final String method;
public final long amount;
public PaymentRequest(String idempotencyKey, String method, long amount) {
this.idempotencyKey = idempotencyKey;
this.method = method;
this.amount = amount;
}
}
public final class PaymentResponse {
public final boolean success;
public final String providerReference;
public final String errorCode;
public PaymentResponse(boolean success, String providerReference, String errorCode) {
this.success = success;
this.providerReference = providerReference;
this.errorCode = errorCode;
}
}
public interface PaymentProvider {
String key();
PaymentResponse process(PaymentRequest request);
}
public final class ProviderRegistry {
private final Map<String, PaymentProvider> providers = new ConcurrentHashMap<>();
public void register(PaymentProvider provider) {
providers.put(normalize(provider.key()), provider);
}
public Optional<PaymentProvider> lookup(String key) {
return Optional.ofNullable(providers.get(normalize(key)));
}
private String normalize(String key) {
return key.toUpperCase(Locale.ROOT);
}
}
public final class PaymentService {
private final ProviderRegistry registry;
public PaymentService(ProviderRegistry registry) {
this.registry = registry;
}
public PaymentResponse pay(PaymentRequest request) {
if (request == null || request.method == null || request.amount <= 0) {
return new PaymentResponse(false, null, "invalid_request");
}
return registry.lookup(request.method)
.map(provider -> provider.process(request))
.orElseGet(() -> new PaymentResponse(false, null, "unsupported_method"));
}
}
public final class CardProvider implements PaymentProvider {
@Override
public String key() {
return "CARD";
}
@Override
public PaymentResponse process(PaymentRequest request) {
// Call a card gateway through an adapter and map its result.
return new PaymentResponse(true, "gateway-reference", null);
}
}
// Application wiring
ProviderRegistry registry = new ProviderRegistry();
registry.register(new CardProvider());
registry.register(new UpiProvider());
registry.register(new WalletProvider());
PaymentService service = new PaymentService(registry);
PaymentResponse response = service.pay(
new PaymentRequest("idem-1", "CARD", 1000)
);Core design
Use the Strategy pattern to represent each payment method behind a common interface. PaymentService should contain shared workflow concerns, such as request validation, provider selection, idempotency handling, and normalized error handling, while individual providers contain method-specific gateway logic.
A registry or factory maps a payment method key, such as CARD, UPI, or WALLET, to its PaymentProvider implementation. This prevents PaymentService from depending directly on concrete provider classes.
- PaymentProvider: Defines the common operation for processing a payment.
- PaymentService: Coordinates the payment flow and delegates to the selected provider.
- ProviderRegistry: Stores and resolves providers by payment-method key.
- Adapter: Wraps a third-party payment SDK and translates between application models and SDK-specific models.
Adding a payment method
A new payment method should be introduced without modifying PaymentService or existing providers. Create a new PaymentProvider implementation, adapt its external gateway if necessary, and register it with the registry during application configuration or dependency-injection setup.
- Implement: Create a provider that implements the common PaymentProvider contract.
- Adapt: Keep gateway-specific request conversion, response conversion, and error translation inside the provider or an adapter.
- Register: Associate the provider with a unique payment-method key so it can be resolved at runtime.
- Verify: Test that the provider follows the common contract, including success, failure, timeout, and idempotency behavior where supported.
Operational considerations
Keep provider-specific details out of the public response model so callers receive a stable application-level result. Store credentials securely, avoid logging sensitive payment data, and add provider-level monitoring.
Timeouts and carefully controlled retries are important for external gateways. Retries must be compatible with idempotency so that a transient failure does not unintentionally create duplicate charges.
- Security: Protect credentials and avoid exposing sensitive payment information in logs or errors.
- Resilience: Apply timeouts and use idempotency-aware retry behavior for gateway calls.
- Observability: Record provider-level latency, outcomes, and structured diagnostic information.
23A notification service supports Email and SMS. How would you add Push Notification without filling the client code with conditionals?
Use polymorphism: define a NotificationSender interface, provide EmailSender, SmsSender, and PushSender implementations, and have NotificationService obtain the appropriate sender from an injected registry or factory. Client code calls the service with a channel key and notification; it does not contain Email/SMS/Push conditionals. To add Push, implement PushSender and register it during application configuration.
import java.util.HashMap;
import java.util.Map;
public final class NotificationExample {
public interface NotificationSender {
void send(Notification notification);
}
public static final class Notification {
private final String recipient;
private final String subject;
private final String body;
public Notification(String recipient, String subject, String body) {
this.recipient = recipient;
this.subject = subject;
this.body = body;
}
public String recipient() {
return recipient;
}
public String subject() {
return subject;
}
public String body() {
return body;
}
}
public static final class EmailSender implements NotificationSender {
@Override
public void send(Notification notification) {
// Send through an email provider.
}
}
public static final class SmsSender implements NotificationSender {
@Override
public void send(Notification notification) {
// Send through an SMS provider.
}
}
public static final class PushSender implements NotificationSender {
@Override
public void send(Notification notification) {
// Map the notification to a push payload and send it.
}
}
public static final class SenderRegistry {
private final Map<String, NotificationSender> senders = new HashMap<>();
public void register(String channelKey, NotificationSender sender) {
senders.put(channelKey, sender);
}
public NotificationSender resolve(String channelKey) {
NotificationSender sender = senders.get(channelKey);
if (sender == null) {
throw new IllegalArgumentException("Unsupported channel: " + channelKey);
}
return sender;
}
}
public static final class NotificationService {
private final SenderRegistry registry;
public NotificationService(SenderRegistry registry) {
this.registry = registry;
}
public void send(String channelKey, Notification notification) {
registry.resolve(channelKey).send(notification);
}
}
public static void main(String[] args) {
SenderRegistry registry = new SenderRegistry();
registry.register("email", new EmailSender());
registry.register("sms", new SmsSender());
registry.register("push", new PushSender());
NotificationService service = new NotificationService(registry);
service.send("push", new Notification("recipient", "Title", "Body"));
}
}Use a common abstraction
Define an interface such as NotificationSender with a send(Notification) method. Each delivery channel implements this interface and owns its transport-specific behavior.
NotificationService depends on the abstraction and delegates sending to the selected implementation. This is a Strategy-style use of polymorphism: the service invokes the same operation regardless of whether the selected sender is email, SMS, or push.
- EmailSender: Sends notifications through the email transport.
- SmsSender: Sends notifications through the SMS transport.
- PushSender: Builds and sends the push-specific payload through the push transport.
Resolve the sender outside client code
Inject a registry, factory, or dependency-injection configuration into NotificationService. A registry can map channel keys such as email, sms, and push to NotificationSender instances.
The client calls NotificationService.send(channelKey, notification). The service resolves the sender and delegates to it, so the client remains independent of concrete channel classes and contains no branching logic.
- Registry: Appropriate when sender instances are configured at startup and selected by a key.
- Factory: Useful when sender creation itself depends on configuration or request-specific context.
- Dependency injection: Can construct and supply the registry or sender implementations during application setup.
Add Push without changing callers
Create PushSender, implement the common interface, and register it under the push key in application wiring. Existing client code and the NotificationService delegation logic do not need to change.
This supports the Open/Closed Principle in practice: behavior is extended by adding a new implementation and configuration rather than editing client-side conditional branches.
- Validation: The resolver should reject or otherwise handle an unsupported channel key.
- Channel-specific data: Keep payload mapping, credentials, provider calls, and delivery constraints inside the relevant sender or its collaborators.
Operational boundaries
A shared Notification model should contain only data common to callers. Each sender can translate that model into the format required by its transport. If a channel needs additional data, model that requirement explicitly rather than assuming every channel accepts identical fields.
Retry behavior, observability, and fallback rules should be placed deliberately. A sender may handle transport-level failures, while a separate orchestration policy can decide whether a failed push should trigger another channel.
- Testing: Unit-test NotificationService with a test registry or test sender, and test each concrete sender independently.
- Configuration: Inject provider-specific configuration into sender implementations rather than exposing it to clients.
24A Bird base class defines fly(), but some birds cannot fly. What is wrong with the design, and how would you improve it?
The problem is that flight is modeled as a universal property of Bird, although it is only a capability of some birds. A Penguin is still a valid Bird but cannot honor a fly() contract. This creates an incorrect abstraction and can violate the Liskov Substitution Principle if clients expect every Bird to fly. Remove fly() from the Bird base class. Model flight as an opt-in capability: for example, let flying birds implement a Flyable interface, and make code that requires flight depend on Flyable rather than Bird. If flight behavior must be shared or changed at runtime, use composition with a FlightBehavior strategy, but expose that behavior only on bird types that can fly. Do not use exceptions or no-op implementations to pretend that non-flying birds support flight.
public final class BirdDesignExample {
// Approach 1: model flight as an opt-in capability.
interface Flyable {
void fly();
}
abstract static class Bird {
private final String name;
protected Bird(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void eat() {
// Common bird behavior.
}
}
static final class Sparrow extends Bird implements Flyable {
Sparrow(String name) {
super(name);
}
@Override
public void fly() {
System.out.println(getName() + " flies.");
}
}
static final class Penguin extends Bird {
Penguin(String name) {
super(name);
}
// Penguin is a Bird, but it is not Flyable.
}
static void letItFly(Flyable bird) {
bird.fly();
}
// Approach 2: compose a flight behavior for bird types that can fly.
interface FlightBehavior {
void fly(String birdName);
}
static final class WingFlight implements FlightBehavior {
@Override
public void fly(String birdName) {
System.out.println(birdName + " flies using its wings.");
}
}
static final class FlyingBird extends Bird implements Flyable {
private FlightBehavior flightBehavior;
FlyingBird(String name, FlightBehavior flightBehavior) {
super(name);
this.flightBehavior = flightBehavior;
}
@Override
public void fly() {
flightBehavior.fly(getName());
}
public void setFlightBehavior(FlightBehavior flightBehavior) {
this.flightBehavior = flightBehavior;
}
}
}Why the base design is incorrect
A base class should contain behavior that all of its valid subtypes can support. Declaring fly() on Bird says that every Bird can fly, which is false for birds such as penguins.
A non-flying subclass is then forced into a misleading implementation: it may throw an exception, do nothing, or require callers to check its concrete type before calling fly(). Each outcome weakens the class contract.
- Incorrect abstraction: Flight is a selective capability, not an essential property of every bird.
- Substitution problem: A Bird reference cannot safely be treated as flyable when some Bird subtypes cannot fulfill fly().
Relevant design principle
This is primarily an Liskov Substitution Principle issue. If Bird promises fly(), every subtype must preserve that promise. A non-flying bird cannot do so meaningfully.
It can also indicate an overly broad interface: clients that only need common bird behavior should not be coupled to flight. Single Responsibility Principle may support separating flight behavior, but it is not the central problem; the key issue is the invalid base-class contract.
Improved design
Keep Bird focused on behavior shared by all birds, such as eating or sleeping. Represent flight separately, so only birds that can fly expose a flight operation.
A Flyable capability interface is usually the simplest solution. Client code that needs flight should accept Flyable, not Bird. Alternatively, composition with a FlightBehavior strategy is useful when multiple flight implementations are shared or when the behavior must be changed at runtime.
- Capability interface: Flying bird classes implement Flyable; non-flying bird classes do not.
- Composition: A flying bird delegates to a FlightBehavior object. Non-flying birds should not expose a fly operation merely to delegate to a failure behavior.
25What is upcasting and downcasting, and why can downcasting be unsafe?
Upcasting means treating an object of a subtype as one of its supertypes, such as assigning a Cat object to an Animal reference. It is normally implicit and safe because the object is already an instance of the supertype. Downcasting means converting a supertype reference back to a subtype reference. It requires an explicit cast because the reference may actually point to a different subtype. In Java, an invalid downcast throws ClassCastException at runtime. Downcasting should be used only when the runtime type is known or checked; otherwise, prefer polymorphism or a better-typed design.
class Animal {
void speak() {
System.out.println("Animal");
}
}
class Cat extends Animal {
@Override
void speak() {
System.out.println("Meow");
}
void purr() {
System.out.println("Purr");
}
}
class Dog extends Animal {
@Override
void speak() {
System.out.println("Woof");
}
}
public class CastDemo {
public static void main(String[] args) {
Animal a = new Cat(); // Upcast: Cat to Animal
a.speak(); // Prints "Meow"
if (a instanceof Cat) {
Cat cat = (Cat) a; // Valid downcast
cat.purr();
}
Animal b = new Dog();
Cat wrong = (Cat) b; // Throws ClassCastException at runtime
}
}Upcasting
Upcasting assigns or passes a subtype object through a superclass or interface reference. For example, a Cat can be used as an Animal because Cat is an Animal.
Upcasting changes the static type of the reference, not the object itself. The runtime object remains a Cat, so overridden instance methods still use the Cat implementation.
- Safety: It is safe when the subtype relationship is valid, because every subtype object is also an object of its supertypes.
- Syntax: In Java, it is implicit; no cast syntax is required.
Downcasting
Downcasting converts a reference whose static type is a superclass or interface into a reference to a more specific subtype. It is used when subtype-specific operations are needed.
The cast is explicit because the compiler cannot generally prove that the referenced runtime object has the requested subtype.
- Example: A reference declared as Animal can be cast to Cat only if the object it refers to is actually a Cat or a subtype of Cat.
Why downcasting can be unsafe
A supertype reference can refer to many different concrete subtypes. Therefore, a cast to one particular subtype may be incorrect even though the types are related at compile time.
In Java, an invalid checked downcast fails at runtime with ClassCastException. In languages with unchecked casts, the consequences of an invalid cast are language- and implementation-dependent.
- Runtime mismatch: If an Animal reference points to a Dog, casting it to Cat is invalid.
- Design coupling: Frequent downcasts make code depend on concrete implementations, which reduces the benefits of polymorphism and can make the code more fragile.
Using downcasts safely
When a downcast is genuinely required, verify the runtime type before casting, such as with instanceof in Java. Prefer defining the needed operation on the supertype or interface when all relevant implementations can support it.
Generics and well-designed APIs can also preserve type information and reduce the need for casts.
- Runtime check: Use instanceof before a Java downcast when the runtime type is uncertain.
- Preferred design: Use polymorphism so callers invoke behavior through the common supertype rather than inspecting and casting concrete subtypes.
