C#は、Microsoftが開発したモダンなマルチパラダイムプログラミング言語であり、その設計の核心にはオブジェクト指向プログラミング(OOP: Object-Oriented Programming)があります。
現代のソフトウェア開発において、大規模かつ複雑なシステムを効率的に構築し、長期的な保守性を維持するためには、オブジェクト指向の深い理解が欠かせません。
本記事では、C#におけるオブジェクト指向の基礎から、その根幹を支える「4大原則」、そして実戦で役立つ高度なテクニックまでを網羅的に解説します。
これからC#を本格的に学びたい方はもちろん、基礎を再確認してより堅牢なコードを書きたいエンジニアの方にとっても、実践的なガイドとなる内容を目指します。
オブジェクト指向プログラミングの本質とは
オブジェクト指向とは、プログラムを「命令の羅列」として捉えるのではなく、データとその操作をセットにした「モノ(オブジェクト)」の集まりとして捉える考え方です。
従来の構造化プログラミングでは、データと処理(関数)が分離していたため、プログラムの規模が大きくなるにつれて「どの関数がどのデータを書き換えているのか」を把握することが困難になり、修正が予期せぬバグを引き起こす原因となっていました。
これに対し、オブジェクト指向では「データ」と「それを扱う手続き」を一つの型(クラス)の中に閉じ込めることで、コードの独立性を高め、再利用性や拡張性を飛躍的に向上させています。
クラスとインスタンスの関係
オブジェクト指向を理解する上で最初に押さえるべきは、「クラス(Class)」と「インスタンス(Instance)」の違いです。
- クラス:オブジェクトの設計図。どのようなデータ(フィールド/プロパティ)を持ち、どのような振る舞い(メソッド)をするかを定義します。
- インスタンス:設計図(クラス)を基に、メモリ上に実体化された「モノ」。一つのクラスから複数のインスタンスを生成でき、それぞれが異なる状態(データ)を保持できます。
基本的なクラスの定義とインスタンス化
C#で最も基本的なクラスの定義と、その使用方法を以下のコードに示します。
using System;
namespace OopBasic
{
// クラス(設計図)の定義
public class Car
{
// プロパティ:データ(状態)を保持
public string Model { get; set; }
public int Speed { get; private set; }
// コンストラクタ:インスタンス化の際に実行される初期化処理
public Car(string model)
{
Model = model;
Speed = 0;
}
// メソッド:振る舞い(操作)を定義
public void Accelerate(int amount)
{
Speed += amount;
Console.WriteLine($"{Model}が加速しました。現在の速度: {Speed} km/h");
}
}
class Program
{
static void Main(string[] args)
{
// インスタンスの生成(new演算子を使用)
Car myCar = new Car("スポーツカー");
Car hisCar = new Car("ファミリーカー");
// それぞれのインスタンスに対してメソッドを呼び出す
myCar.Accelerate(30);
hisCar.Accelerate(15);
// インスタンスごとに状態が独立していることを確認
Console.WriteLine($"{myCar.Model}の最終速度: {myCar.Speed}");
Console.WriteLine($"{hisCar.Model}の最終速度: {hisCar.Speed}");
}
}
}
スポーツカーが加速しました。現在の速度: 30 km/h
ファミリーカーが加速しました。現在の速度: 15 km/h
スポーツカーの最終速度: 30
ファミリーカーの最終速度: 15
このように、クラスという共通の枠組みを使いながらも、各インスタンス(myCarやhisCar)は個別の状態を保持できるのがオブジェクト指向の大きな特徴です。
オブジェクト指向の4大原則
C#を使いこなすためには、オブジェクト指向の根幹を成す「4大原則」を理解し、適切に適用する必要があります。
これらは以下の4つです。
- カプセル化(Encapsulation)
- 継承(Inheritance)
- ポリモーフィズム(Polymorphism:多態性)
- 抽象化(Abstraction)
それぞれの概念がなぜ重要で、C#でどのように実現されるのかを詳しく見ていきましょう。
1. カプセル化(Encapsulation)
カプセル化とは、オブジェクトの内部状態を隠蔽し、外部から直接アクセスさせないようにすることです。
これにより、データの不正な書き換えを防ぎ、オブジェクトの整合性を保つことができます。
アクセス修飾子による制御
C#では、アクセス修飾子(Access Modifiers)を使用して、公開範囲を厳密に管理します。
| 修飾子 | 説明 |
|---|---|
public | どこからでもアクセス可能。 |
private | 同じクラス内からのみアクセス可能(デフォルト)。 |
protected | 同じクラスおよび派生クラスからアクセス可能。 |
internal | 同じアセンブリ(プロジェクト)内からのみアクセス可能。 |
プロパティの活用
C#には、フィールドに対する安全なアクセスを提供するための「プロパティ」という強力な機能があります。
単なる変数の公開ではなく、読み取り専用にしたり、値のセット時にバリデーション(妥当性確認)を行ったりすることが可能です。
実践的なカプセル化の例
public class BankAccount
{
// 外部から直接書き換えられないようにprivateで保持
private decimal _balance;
// 読み取り専用のプロパティ
public decimal Balance => _balance;
// メソッドを通じてのみ状態を変更させる
public void Deposit(decimal amount)
{
if (amount <= 0)
{
throw new ArgumentException("預金額は正の値である必要があります。");
}
_balance += amount;
Console.WriteLine($"{amount}円入金しました。");
}
public void Withdraw(decimal amount)
{
if (amount > _balance)
{
Console.WriteLine("残高不足です。");
return;
}
_balance -= amount;
Console.WriteLine($"{amount}円出金しました。");
}
}
この例では、_balanceフィールドを直接操作することはできず、必ずDepositやWithdrawといった「正しい手続き」を通す必要があります。
これがカプセル化の最大のメリットである「データの保護」と「信頼性の確保」です。
2. 継承(Inheritance)
継承とは、既存のクラス(親クラス/基底クラス)の性質を引き継いで、新しいクラス(子クラス/派生クラス)を作成する仕組みです。
これにより、コードの再利用性が高まり、共通の機能を一箇所で管理できるようになります。
継承の書き方
C#では:を使って継承を表現します。
// 親クラス(基底クラス)
public class Animal
{
public string Name { get; set; }
public void Eat() => Console.WriteLine($"{Name}が食事をしています。");
}
// 子クラス(派生クラス)
public class Dog : Animal
{
public void Bark() => Console.WriteLine("ワンワン!");
}
class Program
{
static void Main()
{
Dog myDog = new Dog { Name = "ポチ" };
myDog.Eat(); // 親クラスのメソッドを使用可能
myDog.Bark(); // 自身で定義したメソッド
}
}
継承の注意点:Is-a関係
継承を使用する際の重要なルールは、子クラスが親クラスの性質を完全に備えている「Is-a関係(~は~の一種である)」が成立していることです(例:犬は動物の一種である)。
単に「一部の機能が似ているから」という理由で継承を使うと、設計が複雑になり、予期せぬ挙動を生む原因となります。
このような場合は、後述する「コンポジション(構成)」を検討するのが定石です。
3. ポリモーフィズム(Polymorphism:多態性)
ポリモーフィズムとは、同じ名前のメソッドが、呼び出し側のオブジェクトの型によって異なる振る舞いをする性質のことです。
これは「共通のインターフェースで異なる実装を扱う」ことを可能にし、プログラムの柔軟性を劇的に高めます。
メソッドのオーバーライド
C#では、親クラスのメソッドに virtual キーワードを付け、子クラスで override キーワードを使って再定義することでポリモーフィズムを実現します。
public class Shape
{
// 仮想メソッド
public virtual void Draw() => Console.WriteLine("図形を描画します。");
}
public class Circle : Shape
{
public override void Draw() => Console.WriteLine("円を描画します。");
}
public class Rectangle : Shape
{
public override void Draw() => Console.WriteLine("四角形を描画します。");
}
class Program
{
static void Main()
{
// 親クラスの型としてリストを作成
List<Shape> shapes = new List<Shape>
{
new Circle(),
new Rectangle(),
new Shape()
};
// すべてをShapeとして扱いながら、実体に応じた動作をする
foreach (var shape in shapes)
{
shape.Draw();
}
}
}
円を描画します。
四角形を描画します。
図形を描画します。
この仕組みにより、新しい図形(例えばTriangle)を追加した際も、呼び出し側のコード(foreachループなど)を一切変更する必要がありません。
これこそがオブジェクト指向が変更に強いと言われる大きな理由の一つです。
4. 抽象化(Abstraction)
抽象化とは、オブジェクトの複雑な内部詳細を隠し、外部に対して必要な情報(インターフェース)だけを公開することです。
C#では「抽象クラス(abstract class)」と「インターフェース(interface)」の2つの仕組みを使い分けます。
抽象クラス(abstract class)
抽象クラスは、それ自体をインスタンス化することはできず、継承されることを前提としたクラスです。
「共通の機能(実装)」と「子クラスで必ず実装すべきルール(抽象メソッド)」を混在させることができます。
インターフェース(interface)
インターフェースは、実装を一切持たず(C# 8.0以降はデフォルト実装も可能ですが、基本は定義のみ)、クラスが持つべき「振る舞いの規約」だけを定義します。
C#ではクラスの多重継承は禁止されていますが、インターフェースは複数の実装が可能です。
インターフェースの実践例
// ログ出力という振る舞いを定義
public interface ILogger
{
void Log(string message);
}
// ファイルに出力する実装
public class FileLogger : ILogger
{
public void Log(string message) => Console.WriteLine($"[File] {message}");
}
// データベースに出力する実装
public class DatabaseLogger : ILogger
{
public void Log(string message) => Console.WriteLine($"[DB] {message}");
}
public class BusinessLogic
{
private readonly ILogger _logger;
// 特定のクラスではなく、インターフェースに依存させる(依存性の注入)
public BusinessLogic(ILogger logger)
{
_logger = logger;
}
public void DoWork()
{
_logger.Log("処理を開始しました。");
// ... 何らかの処理 ...
}
}
このように、インターフェースを用いることで「具体的な実装クラス」と「それを利用するクラス」の結びつきを弱める(疎結合にする)ことができ、テストの容易性や部品の交換可能性が向上します。
現代のC#におけるオブジェクト指向の進化
C#は進化の過程で、オブジェクト指向をより簡潔に、そして安全に扱うための新機能を取り入れ続けています。
1. レコード(record)
C# 9.0から導入された record は、データの保持に特化した参照型です。
デフォルトで「値ベースの比較」が可能であり、不変性(Immutability)を重視した設計に最適です。
// 1行で定義可能なプライマリコンストラクタ
public record User(int Id, string Name);
var user1 = new User(1, "Alice");
var user2 = new User(1, "Alice");
// インスタンスが別でも、値が同じならTrueになる
Console.WriteLine(user1 == user2); // True
2. プライマリコンストラクタ
C# 12から、通常のクラスでもプライマリコンストラクタが利用可能になりました。
これにより、依存性の注入(DI)などを行う際のボイラープレートコード(定型文)を大幅に削減できます。
// クラス名の直後に引数を定義
public class OrderService(ILogger logger, IRepository repository)
{
public void Process()
{
logger.Log("Processing order...");
repository.Save();
}
}
実践的なオブジェクト指向設計のためのガイドライン
単にクラスや継承を使えばオブジェクト指向になるわけではありません。
より良い設計のために、以下の原則を意識することが推奨されます。
SOLID原則の意識
オブジェクト指向設計のバイブルとも言える「SOLID原則」のいくつかを抜粋して紹介します。
- 単一責任の原則(SRP):一つのクラスは一つの役割だけを持つべき。
- 開放閉鎖の原則(OCP):拡張に対しては開いており、修正に対しては閉じているべき(ポリモーフィズムで解決)。
- 依存関係逆転の原則(DIP):具体的なクラスではなく、抽象(インターフェース)に依存すべき。
継承よりもコンポジションを優先する
継承(Inheritance)は強力ですが、階層が深くなると複雑化し、柔軟性が失われます。
これを避けるために、「あるクラスの機能を使いたい場合は、そのクラスのインスタンスをフィールドとして持つ(Composition)」という手法が推奨されます。
総合演習:ECシステムの簡易モデル
これまでの知識を総動員して、簡易的な商品管理と割引システムのコードを書いてみましょう。
using System;
using System.Collections.Generic;
namespace OopPractical
{
// 抽象化:割引戦略のインターフェース
public interface IDiscountStrategy
{
decimal ApplyDiscount(decimal price);
}
// カプセル化:割引の実装
public class NoDiscount : IDiscountStrategy
{
public decimal ApplyDiscount(decimal price) => price;
}
public class PercentageDiscount(decimal percent) : IDiscountStrategy
{
public decimal ApplyDiscount(decimal price) => price * (1 - percent / 100);
}
// クラスとプロパティ
public class Product(string name, decimal price)
{
public string Name { get; } = name;
public decimal BasePrice { get; } = price;
}
// 注文管理クラス(コンポジションの活用)
public class Order
{
private readonly List<Product> _items = new();
public IDiscountStrategy DiscountStrategy { get; set; } = new NoDiscount();
public void AddProduct(Product product) => _items.Add(product);
public decimal CalculateTotal()
{
decimal total = 0;
foreach (var item in _items)
{
total += item.BasePrice;
}
// ポリモーフィズムにより、実行時に割引方法が切り替わる
return DiscountStrategy.ApplyDiscount(total);
}
public void PrintReceipt()
{
Console.WriteLine("--- 領収書 ---");
foreach (var item in _items)
{
Console.WriteLine($"{item.Name}: {item.BasePrice}円");
}
Console.WriteLine($"合計(割引後): {CalculateTotal()}円");
}
}
class Program
{
static void Main()
{
var order = new Order();
order.AddProduct(new Product("C#本", 3000));
order.AddProduct(new Product("キーボード", 12000));
// 割引なしの場合
order.PrintReceipt();
// 20%割引を適用(動的に振る舞いを変更)
order.DiscountStrategy = new PercentageDiscount(20);
Console.WriteLine("\n[キャンペーン適用後]");
order.PrintReceipt();
}
}
}
--- 領収書 ---
C#本: 3000円
キーボード: 12000円
合計(割引後): 15000円
[キャンペーン適用後]
--- 領収書 ---
C#本: 3000円
キーボード: 12000円
合計(割引後): 12000.0円
このコードでは、割引の計算ロジックを Order クラスの中に直接書くのではなく、IDiscountStrategy インターフェースを介して外出しにしています。
これにより、新しい割引ルール(「1000円引き」など)が必要になっても、既存の Order クラスを修正することなく、新しいクラスを追加するだけで対応が可能です。
まとめ
C#におけるオブジェクト指向は、単なる機能の集まりではなく、「変化に強く、理解しやすいコードを書くための思考フレームワーク」です。
本記事で解説した4大原則をもう一度整理します。
- カプセル化で、データを不正なアクセスから守り、整合性を保つ。
- 継承で、共通機能を再利用し、コードの重複を減らす。
- ポリモーフィズムで、呼び出し側を修正せずに、実行時の振る舞いを柔軟に切り替える。
- 抽象化で、具体的な詳細を切り離し、システム全体の依存関係を整理する。
これらの概念は、一朝一夕で完璧にマスターできるものではありません。
しかし、日々のコーディングの中で「このクラスの責任は何か?」「継承を使うべきか、インターフェースを使うべきか?」と自問自答を繰り返すことで、自然と洗練された設計ができるようになります。
C#は現代のプログラミングに必要な要素がすべて詰まった強力な言語です。
オブジェクト指向の深い理解を武器に、より高品質なソフトウェア開発を目指していきましょう。






