Skip to content

Chapter 2: Polymorphism - Swapping Inference Engines (Virtual/Override)

Theoretical Foundations

Polymorphism, in the context of building scalable AI systems, is the architectural principle that allows client code to interact with a generic abstraction while the underlying implementation can change dynamically. This section focuses specifically on runtime polymorphism achieved through virtual methods and override specifiers in C#, and how this mechanism is foundational to designing modular AI systems where inference engines can be swapped without modifying the code that consumes them.

The Core Concept: The "Universal Remote" Analogy

Imagine you have a universal remote control designed to operate any television. The remote has a standard set of buttons: "Power," "Volume Up," "Volume Down," and "Change Channel." The remote itself does not contain the circuitry to generate infrared signals specific to a Sony, Samsung, or LG TV. Instead, it relies on a standard protocol.

When you press "Power," the remote sends a generic "Power" signal. The specific TV (Sony, Samsung, etc.) receives this signal and interprets it according to its internal hardware. The Sony TV might turn on a red LED, while the Samsung TV turns on a blue one. The remote (the client) doesn't care about the internal implementation; it only cares that the "Power" button works.

In OOP:

  • The Remote is the Client Code (e.g., an AIChatbot class).
  • The Buttons are the virtual methods defined in an Abstract Base Class (Interface).
  • The Specific TVs are the derived classes (e.g., OpenAIEngine, LocalLlamaEngine).
  • Polymorphism is the ability to point the remote at any TV and have the "Power" button work correctly, regardless of the TV's brand.

In C#, polymorphism is enabled by marking methods as virtual in a base class and redefining them in derived classes using override.

1. The Virtual Method

When a method is marked virtual, it signals to the compiler that the method's implementation in the base class is a default or placeholder definition. It creates a slot in the method table that can be replaced by a derived class.

2. The Override Specifier

The override keyword in a derived class explicitly states that this method is intended to replace the virtual method in the base class. This is a contract: "I am providing a new behavior for this specific type."

3. The Mechanism of Dispatch

When you call a method on a base class reference (e.g., InferenceEngine engine), the runtime checks the actual object type (e.g., OpenAIEngine) and executes the overridden method. This is Late Binding.

Architectural Application: Swapping Inference Engines

In AI development, we often need to switch between models. You might use OpenAI's GPT-4 for high-level reasoning and a local quantized model for privacy-sensitive tasks. Without polymorphism, you would need if/else statements scattered throughout your code, tightly coupling your application to specific implementations.

With polymorphism, we define an Interface (or an Abstract Base Class) that represents the capabilities of any inference engine.

Step 1: Define the Abstraction This interface acts as the "Universal Remote" protocol. It guarantees that any engine implementing it will have a GenerateResponse method.

using System;
using System.Collections.Generic;

// The abstraction defined in a previous chapter (Book 1 or 2)
public interface IInferenceEngine
{
    // A contract that all AI models must fulfill
    string GenerateResponse(string prompt);

    // A property to identify the engine type
    string ModelName { get; }
}

Step 2: Implement Concrete Engines We implement specific engines. These are the "Specific TVs" from our analogy.

// Concrete implementation 1: A cloud-based model
public class OpenAIEngine : IInferenceEngine
{
    private readonly string _apiKey;

    public OpenAIEngine(string apiKey)
    {
        _apiKey = apiKey;
    }

    public string ModelName => "GPT-4";

    // The override implementation for OpenAI
    public string GenerateResponse(string prompt)
    {
        // Simulate API call logic
        Console.WriteLine($"Connecting to OpenAI with key: {_apiKey.Substring(0, 5)}...");
        return $"[OpenAI GPT-4]: Processed '{prompt}'";
    }
}

// Concrete implementation 2: A local model
public class LocalLlamaEngine : IInferenceEngine
{
    private readonly int _gpuLayers;

    public LocalLlamaEngine(int gpuLayers)
    {
        _gpuLayers = gpuLayers;
    }

    public string ModelName => "Llama-3-8B";

    // The override implementation for Local Llama
    public string GenerateResponse(string prompt)
    {
        // Simulate local inference logic
        Console.WriteLine($"Loading {_gpuLayers} layers onto GPU...");
        return $"[Local Llama]: Processed '{prompt}'";
    }
}

Step 3: The Client Code (The Universal Remote) The client code (e.g., a ChatSession class) holds a reference to the IInferenceEngine interface, not the concrete classes. It is completely unaware of whether it is talking to OpenAI or a local model.

public class ChatSession
{
    // The client depends on the abstraction, not the concrete implementation
    private readonly IInferenceEngine _engine;

    public ChatSession(IInferenceEngine engine)
    {
        _engine = engine;
    }

    public void StartChat()
    {
        string prompt = "Explain quantum entanglement.";

        // Polymorphic call: The runtime determines which GenerateResponse to run
        string response = _engine.GenerateResponse(prompt);

        Console.WriteLine($"Received response from {_engine.ModelName}: {response}");
    }
}

Step 4: Runtime Swapping Here is where polymorphism shines. We can swap engines dynamically without changing ChatSession.

public class Program
{
    public static void Main()
    {
        // Scenario 1: Using OpenAI
        IInferenceEngine openAI = new OpenAIEngine("sk-1234567890");
        ChatSession session1 = new ChatSession(openAI);
        session1.StartChat();

        Console.WriteLine("\n--- Switching Engines ---\n");

        // Scenario 2: Switching to Local Llama at runtime
        // Note: The ChatSession class does NOT change.
        IInferenceEngine localLlama = new LocalLlamaEngine(35);
        ChatSession session2 = new ChatSession(localLlama);
        session2.StartChat();
    }
}

Visualizing the Architecture

The following diagram illustrates the relationship between the client, the abstraction, and the concrete implementations.

The diagram visually represents the architectural relationship where the client code instantiates a concrete LocalLlamaEngine (35 billion parameters) through the abstract IInferenceEngine interface, which is then used to initialize a ChatSession for managing conversational interactions.
Hold "Ctrl" to enable pan & zoom

The diagram visually represents the architectural relationship where the client code instantiates a concrete `LocalLlamaEngine` (35 billion parameters) through the abstract `IInferenceEngine` interface, which is then used to initialize a `ChatSession` for managing conversational interactions.

Deep Dive: Why This Matters for AI Data Structures

In AI, we deal with complex data structures like Tensors. Different engines handle tensors differently:

  1. OpenAI Engine: Tensors are abstract concepts sent over HTTP as JSON arrays.
  2. Local Llama Engine: Tensors are concrete blocks of memory (float arrays) manipulated via CUDA or CPU instructions.

Polymorphism allows us to abstract these differences.

Example: Tensor Operations

Suppose we want to perform a matrix multiplication as part of an AI workflow. We define an interface for tensor operations.

// Abstracting Tensor Operations
public interface ITensorOperation
{
    // Returns a generic object representing the tensor result
    object Multiply(object matrixA, object matrixB);
}

// Implementation for a high-performance GPU library (e.g., a wrapper around a C++ library)
public class CudaTensorOperation : ITensorOperation
{
    public object Multiply(object matrixA, object matrixB)
    {
        // Unmanaged memory pointers logic here
        Console.WriteLine("Performing high-speed matrix multiplication on GPU...");
        return new { Data = "GPU Result" };
    }
}

// Implementation for a standard .NET library (e.g., Math.NET)
public class CpuTensorOperation : ITensorOperation
{
    public object Multiply(object matrixA, object matrixB)
    {
        // Managed array logic here
        Console.WriteLine("Performing matrix multiplication on CPU...");
        return new { Data = "CPU Result" };
    }
}

By using polymorphism, an AI pipeline (like a Neural Network layer) can accept an ITensorOperation interface. It doesn't need to know if the calculation happens on a GPU or CPU. If we later optimize the CudaTensorOperation or switch to a TPU (Tensor Processing Unit) implementation, the pipeline code remains untouched.

Edge Cases and Nuances

1. The "New" Keyword vs. "Override"

A common pitfall in C# is using the new keyword instead of override in a derived class.

public class Base
{
    public virtual void Compute() { Console.WriteLine("Base Compute"); }
}

public class Derived : Base
{
    // Hides the Base method, does NOT override it
    public new void Compute() { Console.WriteLine("Derived Compute"); }
}

// Usage
Base obj = new Derived();
obj.Compute(); // Output: "Base Compute" (due to hiding)

In AI systems, using new breaks polymorphism. If OpenAIEngine used new instead of override, calling GenerateResponse via an IInferenceEngine reference would execute the default interface implementation (if available) or the base implementation, not the specific OpenAI logic. Always use override when intending to change behavior.

2. Abstract Classes vs. Interfaces

While this section focuses on virtual and override (which apply to classes), in AI architecture, we often use Interfaces (Book 1 concept) to define pure contracts. However, abstract base classes are useful when you want to provide partial implementation (e.g., a base NeuralLayer class that handles tensor initialization but leaves the forward pass abstract).

public abstract class NeuralLayer
{
    // Common logic shared by all layers
    protected void InitializeWeights()
    {
        Console.WriteLine("Initializing random weights...");
    }

    // Specific logic must be implemented by derived layers
    public abstract void ForwardPass(object input);
}

public class DenseLayer : NeuralLayer
{
    public override void ForwardPass(object input)
    {
        InitializeWeights(); // Inherited method
        Console.WriteLine("Dense layer forward pass...");
    }
}

3. Virtual Properties

Polymorphism applies to properties as well. This is vital for AI systems that need to report metadata dynamically.

public interface IModel
{
    // Virtual property for dynamic metadata
    virtual string Metadata { get; }
}

public class VisionModel : IModel
{
    public string Metadata => "Type: Vision, Input: Image, Output: Label";
}

public class TextModel : IModel
{
    public string Metadata => "Type: Text, Input: String, Output: String";
}

Summary of Implications

By utilizing virtual and override, we achieve:

  1. Decoupling: The ChatSession (client) is decoupled from the specific AI provider.
  2. Testability: We can easily create a MockEngine that implements IInferenceEngine to test the ChatSession without making actual API calls.
  3. Extensibility: Adding a new engine (e.g., ClaudeEngine or HuggingFaceEngine) requires zero changes to existing client code. We simply create a new class implementing the interface.
  4. Unified Tensor Handling: Different hardware backends (CUDA, Metal, CPU) can expose the same interface for tensor math, allowing the AI model to run on different devices transparently.

This theoretical foundation establishes the "what" and "why" of polymorphism, setting the stage for the practical implementation of swapping engines in subsequent sections.

Basic Code Example

Imagine a smart home controller that needs to manage the temperature. You have two competing strategies to achieve this: an AI-based Predictor that looks at weather patterns to pre-emptively adjust the HVAC, and a Rule-based Thermostat that simply reacts to the current room temperature.

The goal is to write a SmartHomeController that can command "Adjust Temperature" without knowing how the adjustment is calculated. We want to be able to swap the underlying engine (the Predictor or the Thermostat) at runtime, perhaps even based on the time of day or subscription status, without changing a single line of code in the controller.

This is the essence of polymorphism: programming to an interface, not an implementation.

The Code Example

using System;

namespace SmartHomeSystem
{
    // 1. THE CONTRACT (Abstract Base Class / Interface)
    // We define what it means to be a "Temperature Control Engine".
    // It MUST provide a method to calculate the target temperature.
    // This acts as our "Tensor Operation" interface—a standard way to process inputs (sensor data)
    // into outputs (target temp).
    public abstract class TemperatureControlEngine
    {
        // 'abstract' enforces that derived classes MUST implement this method.
        // 'virtual' allows derived classes to override it if they want to provide
        // a specific implementation.
        public abstract double CalculateTargetTemp(double currentTemp, double desiredTemp);
    }

    // 2. CONCRETE IMPLEMENTATION A: The Neural Network Predictor
    // This simulates a complex AI model that predicts the best temperature
    // based on external factors (simulated here by simple math).
    public class NeuralNetPredictor : TemperatureControlEngine
    {
        // 'override' explicitly states that this method replaces the abstract definition
        // in the base class. The compiler checks that the signature matches exactly.
        public override double CalculateTargetTemp(double currentTemp, double desiredTemp)
        {
            // Simulating complex tensor operations/logic:
            // The AI might decide to overshoot slightly to save energy later.
            double predictedLoad = 1.5; // Simulated load factor
            double target = desiredTemp * predictedLoad - (currentTemp * 0.5);

            Console.WriteLine($"[AI Predictor] Calculated Target: {target}°C based on predictive models.");
            return target;
        }
    }

    // 3. CONCRETE IMPLEMENTATION B: The Rule-Based Expert System
    // This is a simpler, deterministic engine. It just tries to reach the desired temp.
    public class ExpertSystemThermostat : TemperatureControlEngine
    {
        public override double CalculateTargetTemp(double currentTemp, double desiredTemp)
        {
            // Logic: If we are far off, jump to desired. If close, hold steady.
            double difference = Math.Abs(currentTemp - desiredTemp);
            double target = (difference > 2.0) ? desiredTemp : currentTemp;

            Console.WriteLine($"[Expert System] Calculated Target: {target}°C based on strict rules.");
            return target;
        }
    }

    // 4. THE CLIENT CODE: The Smart Home Controller
    // This class does NOT know which engine it is using. 
    // It only knows it has a 'TemperatureControlEngine'.
    public class SmartHomeController
    {
        private TemperatureControlEngine _engine;

        // Constructor Injection: We pass the engine in.
        // This allows us to swap engines easily.
        public SmartHomeController(TemperatureControlEngine engine)
        {
            _engine = engine;
        }

        public void RunCycle(double current, double desired)
        {
            Console.WriteLine("--- Controller Cycle Start ---");

            // POLYMORPHISM IN ACTION:
            // The code below calls 'CalculateTargetTemp'. 
            // At runtime, the CLR looks at the actual object type stored in _engine
            // (either NeuralNetPredictor or ExpertSystemThermostat) and executes that specific version.
            double result = _engine.CalculateTargetTemp(current, desired);

            Console.WriteLine($"Setting HVAC to: {result}°C");
            Console.WriteLine("--- Controller Cycle End ---\n");
        }

        // Method to swap engines dynamically
        public void SwapEngine(TemperatureControlEngine newEngine)
        {
            Console.WriteLine(">>> Swapping Inference Engine...");
            _engine = newEngine;
        }
    }

    public class Program
    {
        public static void Main(string[] args)
        {
            // Scenario 1: Daytime, we use the AI Predictor for efficiency
            var aiEngine = new NeuralNetPredictor();
            var controller = new SmartHomeController(aiEngine);

            controller.RunCycle(current: 22.0, desired: 24.0);

            // Scenario 2: Nighttime, we swap to the reliable Expert System
            // Notice the 'RunCycle' method is called exactly the same way.
            var ruleEngine = new ExpertSystemThermostat();
            controller.SwapEngine(ruleEngine);

            controller.RunCycle(current: 22.0, desired: 24.0);
        }
    }
}

Visualizing the Architecture

The following diagram illustrates the relationship between the abstract base class and the concrete implementations. The Controller depends only on the abstraction, shielding it from the complexity of the specific engines.

A diagram shows the Controller class pointing to an abstract EngineBase class, which branches out to multiple concrete engine implementations like GasEngine and ElectricEngine, illustrating how the Controller depends only on the abstraction to shield itself from specific engine details.
Hold "Ctrl" to enable pan & zoom

A diagram shows the Controller class pointing to an abstract EngineBase class, which branches out to multiple concrete engine implementations like GasEngine and ElectricEngine, illustrating how the Controller depends only on the abstraction to shield itself from specific engine details.

Step-by-Step Explanation

  1. Defining the Abstract Contract (TemperatureControlEngine):

    • We create an abstract class. This serves as the blueprint. It cannot be instantiated directly (you cannot "run" a generic engine; you must run a specific engine).
    • The method CalculateTargetTemp is marked abstract. This is a powerful enforcement tool. It tells the compiler: "Any class that claims to be a TemperatureControlEngine must know how to calculate a target temperature." This guarantees that the SmartHomeController will never encounter an engine that lacks this capability.
  2. Implementing the Concrete Engines:

    • NeuralNetPredictor: Represents the "Advanced AI" approach. It overrides the abstract method to provide complex logic. In a real-world scenario, this class might load a ONNX model or perform matrix multiplication on tensors.
    • ExpertSystemThermostat: Represents the "Legacy/Rule-based" approach. It overrides the method with simple conditional logic.
    • The override Keyword: This is crucial. It ensures that the method in the derived class is intended to replace the base class definition. If the signature in the derived class doesn't match the base class exactly, the compiler will throw an error, preventing subtle runtime bugs.
  3. The Controller and Dependency Injection:

    • The SmartHomeController holds a reference of type TemperatureControlEngine (the abstract type), not NeuralNetPredictor (the concrete type).
    • In the constructor, we inject the specific engine we want to use. This is the "Strategy Pattern" in action.
  4. Runtime Polymorphism (The "Magic"):

    • When RunCycle calls _engine.CalculateTargetTemp(...), the program does not decide which code to run at compile time.
    • Instead, at runtime, the system checks the actual object stored in _engine. If it's a NeuralNetPredictor, it runs the AI logic. If it's an ExpertSystemThermostat, it runs the rule logic.
    • This allows the SmartHomeController to remain completely unchanged even if we add a third engine (e.g., a MachineLearningReinforcement engine) later.

Common Pitfalls

1. Forgetting the abstract or virtual Keyword in the Base Class If you define public double CalculateTargetTemp(...) in the base class without virtual or abstract, the compiler will hide the base method if you define a method with the same name in a derived class. This is called "method hiding" (using the new keyword implicitly). It breaks polymorphism because the controller might call the base method even if you passed in a derived object.

2. Forgetting the override Keyword in the Derived Class If you omit override in the derived class, the compiler assumes you are simply creating a new method that happens to have the same name. This is also method hiding. The SmartHomeController, holding a reference to the base class, will call the base class method (which might be empty or throw an exception) instead of your intended derived logic.

3. Changing Method Signatures Polymorphism relies on strict adherence to the contract. If the base class defines CalculateTargetTemp(double a, double b), the derived class must use the exact same parameter types and return type. If you change it to CalculateTargetTemp(int a, int b), you are no longer overriding; you are overloading. The virtual dispatch mechanism will fail to find the intended method.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.