概要
オブジェクト指向設計の5つの基本原則をまとめたSOLID原則について、まとめてみました。
オブジェクト指向が生まれた背景
1960年代、当時のプログラムは手続き型が主流でした。しかし、ソフトウェアの規模が大きくなるにつれてコードは複雑化し、大規模開発ではスパゲッティコード化して管理が困難になっていました。主な原因としては、関数同士の依存関係が強くなったり、グローバル変数が乱立したりすることでした。
この課題を解決するために、ノルウェーの研究者であるダールとニガードが「Simula67」というプログラミング言語を開発しました。Simula67は世界初のオブジェクト指向言語とされていて、クラスやオブジェクトの概念を導入した点で当時はとても画期的でした。
SOLID原則について
SOLID原則は、
- Single Responsibility Principle (単一責任の原則)
- Open/Closed Principle (開放/閉鎖の原則)
- Liskov Substitution Principle (リスコフの置換原則)
- Interface Segregation Principle (インターフェース分離の原則)
- Dependency Inversion Principle (依存性逆転の原則)
の5つの頭文字をとったものです。
1980年代から1990年代、オブジェクト指向のプログラミングが普及し始めた時期に、再利用性や保守性の高いコードを書くための模索がされるようになりました。その時期にバーバラ・リスコフが「リスコフの置換原則(LSP)」を提唱しました。
その後、2000年頃にロバート・マーチンが、複数の設計原則を体系的にまとめて「SOLID」という頭文字で紹介しました。
以下から、一つずつ記載していきます。
Single Responsibility Principle
「クラスは一つのことだけを行うべき」という意味です。
一つのクラスが複数の責任を持っていると、ある機能の変更が別の機能に影響を与えてしまう可能性があります。また、一つのクラスにいろいろな機能を持たせすぎると、そのクラスが特定の文脈の場合のみ使える、というようにほかの場面で使いまわしがききにくくなります (シンプルなクラスの方が、ほかの場面でも使用しやすいです) 。ほかにも、コードを読んだ人がそのクラスは何をしているクラス (何の責任を負っているクラス) か判断しにくく、結局クラス内のコードを読み解かないとクラスのイメージが理解できないようになってしまいます。そのほか、シンプルなクラスの方がユニットテストを行いやすい、という理由もあります。
守っていない例
OrderServiceというクラスが、「計算」「保存」「通知」という複数の責任をもっています。このうちどれか一つの仕様変更があると、このクラス内の修正になってしまいます。
using System;
using System.IO;
using System.Linq;
class Order
{
public int Id { get; set; }
public (string Name, int Quantity, decimal Price)[] Items { get; set; }
public decimal Total { get; set; }
}
class OrderService
{
public void Process(Order order)
{
// (1) 合計計算
// 各商品の合計金額(単価 × 数量)を合計して、注文全体の合計金額を計算
order.Total = order.Items.Sum(i => i.Price * i.Quantity);
// (2) 保存
File.AppendAllText("orders.txt", $"{order.Id},{order.Total}{Environment.NewLine}");
// (3) 通知
// processedは通貨形式 (Currency) で表示
Console.WriteLine($"Order {order.Id} processed: {order.Total:C}");
}
}
class Program
{
static void Main()
{
var order = new Order
{
Id = 1,
Items = new[] { ("Pen", 2, 100m), ("Note", 1, 250m) }
};
new OrderService().Process(order);
}
}
守っている例
「計算」「保存」「通知」がそれぞれ別クラスに分離されています。
using System;
using System.IO;
using System.Linq;
class Order
{
public int Id { get; set; }
public (string Name, int Quantity, decimal Price)[] Items { get; set; }
public decimal Total { get; set; }
}
// 計算用のクラス
class OrderCalculator
{
public void CalculateTotal(Order order)
{
// 各商品の合計金額(単価 × 数量)を合計して、注文全体の合計金額を計算
order.Total = order.Items.Sum(i => i.Price * i.Quantity);
}
}
// 保存用のクラス
class OrderRepository
{
public void Save(Order order)
{
File.AppendAllText("orders.txt", $"{order.Id},{order.Total}{Environment.NewLine}");
}
}
// 通知用のクラス
class Notifier
{
public void NotifyProcessed(Order order)
{
// processedは通貨形式 (Currency) で表示
Console.WriteLine($"Order {order.Id} processed: {order.Total:C}");
}
}
// ワークフロー (計算 → 保存 → 通知) だけを行うクラス
class OrderProcessor
{
private readonly OrderCalculator _calculator;
private readonly OrderRepository _repository;
private readonly Notifier _notifier;
public OrderProcessor(OrderCalculator calculator, OrderRepository repository, Notifier notifier)
{
_calculator = calculator;
_repository = repository;
_notifier = notifier;
}
public void Process(Order order)
{
_calculator.CalculateTotal(order);
_repository.Save(order);
_notifier.NotifyProcessed(order);
}
}
class Program
{
static void Main()
{
var order = new Order
{
Id = 1,
Items = new[] { ("Pen", 2, 100m), ("Note", 1, 250m) }
};
var processor = new OrderProcessor(
new OrderCalculator(),
new OrderRepository(),
new Notifier()
);
processor.Process(order);
}
}
今回の例では極端に一つずつクラスを分けましたが、すべてのケースで分割が必要なわけではありません。例えば将来の変更見込みが低い場合は、あえて一つのクラスにまとめる方がシンプルです (YAGNIの考え方) 。 原則を盲目的にそのまま適用するのは避けるべきです。
Open/Closed Principle
「ソフトウェアのモジュールは、拡張に対して開かれ、修正に対して閉じているべき」という意味です。
新しい機能を追加する際に、既存のコードを変更せずに拡張できるようにすることです。
「拡張に対して開かれている」
新しい機能を追加できるようにするという意味です。
「修正に対して閉じている」
既存のコードを変更しなくても、新しい要件に対応できるようにするという意味です。
既存コードを変更するとバグのリスクや既存機能の破壊が起きやすいので、安全に機能追加できるように、というニュアンスです。
守っていない例
以下のコードでは、機能追加 (typeの追加) のたびに、if文やswitch文で条件分岐を追加し続けてしまいます。これは既存のコードを変更しないと機能拡張できない例です。
※typeのような引数も本来はenum型でないといけないですが、コードの行を少なくするためここではstring型にしています。
class DiscountService
{
public decimal GetDiscount(string type, decimal price)
{
if (type == "Regular")
return price * 0.9m;
else if (type == "VIP")
return price * 0.8m;
else
return price;
}
}
守っている例
こちらの場合、新しいtypeを追加する場合は、新しいクラスを作るだけで対応でき、既存の処理の変更は行わずに済みます。
interface IDiscount
{
decimal Apply(decimal price);
}
class RegularDiscount : IDiscount
{
public decimal Apply(decimal price)
{
return price * 0.9m;
}
}
class VipDiscount : IDiscount
{
public decimal Apply(decimal price)
{
return price * 0.8m;
}
}
class DiscountService
{
public decimal GetDiscount(IDiscount discount, decimal price)
{
return discount.Apply(price);
}
}
下記の部分のように、インターフェースを介してそれぞれのクラスのメソッドを呼ぶ処理は「メソッドインジェクション」と呼ばれており、「依存性の注入」の方法のひとつです。ほかにも「コンストラクタインジェクション」や「プロパティインジェクション」がありますが、どれもインターフェースを介してそれぞれのクラスの処理を呼ぶ方法です。この書き方だと、GetDiscountメソッドはIDiscountインターフェスに依存しています。そして、クラスとの依存関係はメソッドの呼び出し時に注入されます。
ここでいう「依存」は、その型がないと動かない、というニュアンスです。
GetDiscountメソッドはIDiscountという型のみ使って処理を実行しています。
public decimal GetDiscount(IDiscount discount, decimal price)
{
return discount.Apply(price);
}
Liskov Substitution Principle
「派生クラスは親クラスと置き換えても正しく動作すべき」という意味です。
継承を使うとき、派生したクラスは基底クラスの仕様を守る必要がある、ということです。
派生クラスは親クラスの期待する動作を壊すような動作はしない、というイメージです。
個人的に一番理解が難しい?ですが、下に例を示します。
守っていない例
using System;
using System.Diagnostics;
class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area => Width * Height;
}
class Square : Rectangle
{
public override int Width
{
get { return base.Width; }
set
{
base.Width = value;
base.Height = value; // ← 幅を変えると高さも変えてしまっている
}
}
public override int Height
{
get { return base.Height; }
set
{
base.Height = value;
base.Width = value; // ← 高さを変えると幅も変えてしまっている
}
}
}
class Program
{
static void Main()
{
Test(new Rectangle());
Test(new Square()); // ここでテストが失敗
}
static void Test(Rectangle r)
{
r.Width = 10;
r.Height = 5;
r.Width = 20;
// 「幅を変えても高さは維持される」はRectangleの仕様だが、Squareクラスではそれが変わってしまっている
Debug.Assert(r.Height == 5, "Heightが変わりました");
}
}
補足:C#でクラスの継承に関係する修飾子 (キーワード) について
クラスの継承が出てきたので、C#でクラスの継承に関係する主な修飾子 (キーワード) を少し記載します。| 修飾子・キーワード | 役割・意味 |
|---|---|
| abstract | 基底クラス内に記載する。抽象クラスや抽象メソッドを定義する。継承先で必ずoverrideが必要。abstractメソッドを持つクラスは必ずクラスもabstractにしなければいけない。 |
| virtual | 基底クラス内に記載する。メソッドやプロパティをoverride可能にする。abstractはoverride必須だが、virtualはoverrideは任意 (基底クラスに実装があるため) 。プロパティにはつけられるが、フィールドにはつけられない。クラスにもつけられない。 |
| protected | 基底クラス内に記載する。派生クラスからはアクセスさせたいが、外部には公開したくない場合に使用する。クラスの継承階層のみでは共有したい情報に付けるイメージ。 |
| override | 派生クラス内に記載する。基底クラスのvirtualやabstractを上書きする。 |
| sealed | 派生クラス内に記載する。これ以上の継承を禁止する。 |
上記の解消方法になりますが、この場合は継承自体をやめて、インターフェースで共通のものをそろえる形になるかなと思います。
継承だとLiskov Substitution Principleを破りやすいですが、インターフェースなら契約だけを共有できるため安全です。
using System;
interface IShape
{
int Area { get; }
}
class Rectangle : IShape
{
public int Width { get; }
public int Height { get; }
public Rectangle(int width, int height)
{
Width = width;
Height = height;
}
public int Area => Width * Height;
}
class Square : IShape
{
public int Size { get; }
public Square(int size)
{
Size = size;
}
public int Area => Size * Size;
}
class Program
{
static void Main()
{
IShape a = new Rectangle(20, 5);
IShape b = new Square(10);
Console.WriteLine(a.Area); // 100
Console.WriteLine(b.Area); // 100
}
}
守っている例
基底クラスでpriceは0以上になっているので、派生クラスでも基底クラスのEnsureValidPrice()メソッドを使用して、priceは0以上という契約を守っています。
using System;
class Discount
{
// 契約: price >= 0
protected void EnsureValidPrice(decimal price)
{
if (price < 0) throw new ArgumentOutOfRangeException(nameof(price), "価格は0以上である必要があります。");
}
public virtual decimal Apply(decimal price)
{
EnsureValidPrice(price);
return price;
}
}
class TenPercent : Discount
{
public override decimal Apply(decimal price)
{
EnsureValidPrice(price); // 基底クラスのチェックを再利用
return price * 0.9m;
}
}
class Program
{
static void Main()
{
Run(new Discount());
Run(new TenPercent());
}
static void Run(Discount d)
{
// どの派生でも契約の下で動作する
var x = d.Apply(100m);
}
}
Interface Segregation Principle
「クライアントは使わないメソッドへの依存を強制されるべきでない」という意味です。
プログラム的には、インターフェースは小さくして役割ごとに分割するべき、というイメージです。一つの大きなインターフェースに関係ない機能が混ざっていると、実装側が不要なメソッドを無理に実装することになってしまい、保守性が下がってしまいます。
守っていない例
OldPrinterクラスは印刷しかできませんが、Scan()やFax()を無理に実装させられています。IMachineを使うことで、使えない機能 (不要なメソッド) にも依存してしまうようになっています。
interface IMachine
{
void Print();
void Scan();
void Fax();
}
class OldPrinter : IMachine
{
public void Print()
{
Console.WriteLine("Printing...");
}
public void Scan()
{
throw new NotSupportedException(); // スキャン機能なし (不要なメソッドを強制実装)
}
public void Fax()
{
throw new NotSupportedException(); // FAX機能なし (不要なメソッドを強制実装)
}
}
守っている例
OldPrinterは必要な機能だけのインターフェースを実装しています。これでこのクラスは「印刷だけできる」というのが明確にわかります。
interface IPrinter
{
void Print();
}
interface IScanner
{
void Scan();
}
interface IFax
{
void Fax();
}
class OldPrinter : IPrinter
{
public void Print()
{
Console.WriteLine("Printing...");
}
}
上記から発展して、以下のようなことができます。
ReportServiceクラスは印刷機能だけを使うため、IPrinterにだけ依存しています。
そのため、SimplePrinterのような印刷専用のクラスでも、MultiFunctionDeviceのような 多機能クラスでも、安全に置き換え可能です。
using System;
interface IPrinter
{
void Print();
}
interface IScanner
{
void Scan();
}
interface IFax
{
void Fax();
}
// 印刷だけのクラス
class SimplePrinter : IPrinter
{
public void Print()
{
Console.WriteLine("Print");
}
}
// 印刷、スキャン、FAXの機能があるクラス
class MultiFunctionDevice : IPrinter, IScanner, IFax
{
public void Print()
{
Console.WriteLine("Print");
}
public void Scan()
{
Console.WriteLine("Scan");
}
public void Fax()
{
Console.WriteLine("Fax");
}
}
class ReportService
{
// ここでは印刷だけ使うので IPrinter にだけ依存(最小依存)
public void SendReport(IPrinter printer)
{
printer.Print();
}
}
class Program
{
static void Main()
{
var svc = new ReportService();
svc.SendReport(new SimplePrinter());
svc.SendReport(new MultiFunctionDevice());
}
}
Dependency Inversion Principle
「高水準モジュールは低水準モジュールに依存すべきでない」という意味です。
高水準モジュール
ビジネスロジックやアプリの中核部分のことです。
低水準モジュール
具体的な実装(DBアクセス、ファイル操作、通知など)のことです。
通常は「高水準 → 低水準」という形で依存しがちです。「高水準 → 低水準」という形で依存すると、低水準の変更が高水準にも波及してしまい、保守性が下がってしまいます。
そこで、Dependency Inversion Principleでは両方がインターフェースや抽象クラスに依存するようにします。
守っていない例
OrderServiceクラスはFileLoggerクラスに直接依存しています。
ログをDBやクラウドに変えたく新しいCloudLoggerというクラスを作成した場合、OrderServiceクラスのFileLoggerの部分も修正しなければいけません。
class FileLogger
{
public void Log(string message)
{
Console.WriteLine($"File: {message}");
}
}
class OrderService
{
private readonly FileLogger _logger = new FileLogger(); // 直接依存
public void Process()
{
_logger.Log("Order processed");
}
}
守っている例
以下の場合はOrderServiceはILoggerに依存しているので、新しくCloudLoggerに実装を差し替えることになっても、OrderServiceクラスは一切修正の必要はなくなります。
interface ILogger
{
void Log(string message);
}
class FileLogger : ILogger
{
public void Log(string message)
{
Console.WriteLine($"File: {message}");
}
}
class OrderService
{
private readonly ILogger _logger;
public OrderService(ILogger logger)
{
_logger = logger;
}
public void Process()
{
_logger.Log("Order processed");
}
}
class Program
{
static void Main()
{
var service = new OrderService(new FileLogger());
service.Process();
}
}
補足
自分だけなのかわかりませんが、オブジェクト指向言語において、下位層 (ロジック部分) は結構クラス分けを考えやすいですが、上位層 (全体のフローを司るような、ユースケース層のクラス) はどうしても肥大化してしまいがちでした。
そこで、上位層も以下の考えのもとにプログラムすると、少しすっきりしました。
上位層クラスはシナリオを実現する役割を負っている
上位層のクラスは、複数の機能を組み合わせてシナリオを実現する役割をもっている。そのため、複雑なロジックが上位層に入ってしまった場合、それは設計を見直す必要があるサイン。
Programクラスの内容は最小限に保つ
Windows FormsなどであるProgramクラス (Main関数があるクラス) は、最小限に保ち、フローの流れは専用のクラスに委ねる。Programクラスに「処理の流れ」を書き始めてしまうとすぐに肥大化してしまい、保守性やテストも難しくなる。
終わりに
原則は知識だけ知っていても意味がないと感じました。すべて守るのは難しいこともあるかもしれませんが、小さなコードからでも少しずつ試していくのが大事だと思いました。