Опыт лекций по введению в шаблоны проектирования
Позвольте небольшое предисловие — обозначу в нём цель статьи.
Я по субботам студентам младших курсов преподаю введение в шаблоны проектирования. Вот, хочу поделиться опытом, описать план нескольких первых лекций. Большинству читателей, я полагаю, сам излагаемый мной материал давно знаком, но, возможно, порядок и способ изложения покажутся любопытными.
Слишком часто, увы, нам рассказывают что-то, но не говорят, зачем это нужно, или говорят даже, но будто вскользь. Скажем, обыкновенно, говоря о C#, расскажут, что такое базовый класс и интерфейс, каким синтаксисом нужно пользоваться, чтобы написать их, приведут пример, где базовым будет класс «Птица», а наследниками «Утка» и «Орёл», но зачем всё это нужно, какая от всей — потенциально сложной — иерархии классов достигается польза, не говорят: это будто бы в тени, подразумевается само собою. И вот потому у многих учеников, ещё не успевших набить свои шишки, в голове перевёрнутая картина мира — они неплохо представляют, что за инструменты даны им в руки, но зачем они изобретены и к чему применимы, понимают смутно.
Вот поэтому я сочинил несколько учебных примеров, на которых можно показать зачем нужны некоторые подходы. Правда, придётся принять условность — будем бить из пушки по воробьям, а то и по воображаемым мишеням. Зато пристреляемся и уж во вражеский бруствер точно попадём, случись что.
Сразу скажу, что от вопросов совсем простых мы быстро перейдем к довольно сложным (ну, скажем, к компоновщику), потому читайте до конца, если уж не с начала.
Начнём с того, что решим самым простым образом какую-нибудь задачку. Ну, скажем, выведем на экран n первых простых чисел. Действовать будем совершенно прямолинейно — до оптимальных алгоритмов нам сейчас нет никакого дела.
static void Main()
{
Console.WriteLine("Input n");
var n = int.Parse(Console.ReadLine());
var primeNumbers = new List();
int current = 2;
while (primeNumbers.Count != n)
{
if (primeNumbers.All(x => current % x != 0))
{
primeNumbers.Add(current);
Console.WriteLine(current);
}
current++;
}
}
Ну, здорово. Программа работает. Что дальше? Ну, допустим, мы её опубликуем — пусть люди пользуются нашим модулем для вычисления простых чисел. Будем потихоньку развивать, улучшать, оттачивать — и нам приятно, и людям польза.
Правда, тогда возникает вопрос — мы так и предоставим людям в пользование exe-шник, который требует ввода с клавиатуры? Обычно модули оформляют в библиотеке, скрывая лишнее и выставляя наружу только нужный пользователю метод. Прикинем его сигнатуру:
public int GetPrimeNumber(int n) { ... }
Допустим, так. Правда, это не совсем то, что мы делали раньше. Мы-то выводили n чисел, а тут только последнее. Хорошо, добавим ещё метод, теперь у нас их два. Сразу поместим их в новый класс — просто чтобы лежали вместе и обращаться к ним было удобно.
public class PrimeNumberCalculator
{
public int GetPrimeNumber(int n) { ... }
public int[] GetPrimeNumbers(int n) { ... }
}
Модуль модулем, однако, забывать о нашей программе нельзя. Она должна работать не хуже прежнего. Займёмся переделкой:
public class PrimeNumberCalculator
{
public int GetPrimeNumber(int n)
{
return GetPrimeNumbers(n).Last();
}
public int[] GetPrimeNumbers(int n)
{
var primeNumbers = new List();
int current = 2;
while (primeNumbers.Count != n)
{
if (primeNumbers.All(x => current % x != 0))
{
primeNumbers.Add(current);
Console.WriteLine(current);
}
current++;
}
return primeNumbers.ToArray();
}
}
class Program
{
static void Main()
{
Console.WriteLine("Input n");
var n = int.Parse(Console.ReadLine());
new PrimeNumberCalculator().GetPrimeNumbers(n);
}
}
Всё вроде бы хорошо. Вот только наш класс PrimeNumberCalculator зависит от Console. Что значит, вообще говоря, зависит? То значит, что использует. Наш класс, если вдруг предположить, что консоль перестанет работать, тоже работать не будет. Он требует её как обязательное условие. Зависимость от класса Console сейчас не слишком видна, потому как Console — статический класс. Мы не создаём его экземпляр явно через new, а пользуемся почти как пространством имён. Но всё же зависимость есть, о ней необходимо помнить.
Подчеркнём её. Создадим новый класс, который будет просто вызывать функциональность Console, но сам при этом не будет статическим. Смысла в этом пока немного — мы просто хотим подчеркнуть зависимости класса Calculator
public class MyConsole
{
// заметим, у нас нет зависимости от чтения с консоли, потому не будем включать этот метод
public void WriteLine(string value)
{
Console.WriteLine(value);
}
}
public class PrimeNumberCalculator
{
....
public int[] GetPrimeNumbers(int n)
{
....
new MyConsole().WriteLine(current.ToString());
....
}
}
Когда мы пишем new MyConsole, мы разрешаем зависимость — получаем в своё распоряжение объект, необходимый нам для работы. Вообще говоря дурным тоном считается разрешать зависимости вот так, напрямую создавая нужный объект. Скоро поговорим о том — почему так. Но сперва другой вопрос.
Предположим, тот пользователь, которому мы предоставили наш опубликованный модуль (который и состоял бы пока только из одного класса), хочет написать программу очень похожую на нашу — ему тоже хочется вывести куда-то последовательность простых чисел. Одна беда — он работает в вебе. У него вообще нет никакой консоли.
Хорошо, тогда отчего бы ему не взять уже готовый массив, собранный методом GetPrimeNumbers и не распечатать куда ему вздумается? Хоть в файл, хоть в html-разметку. Можно так? Можно, но тогда поведение поменяется — мы-то получаем число на экран как только оно посчитано, а наш пользователь вынужден будет сперва затратить время на обсчёт всех чисел, и только затем печатать. Не одно и то же — особенно, если вы просите программу выдать 100-тысячное простое число.
Не буду отвлекать читателя конкретной реализацией вывода «куда-нибудь, но не в консоль». Самое простое — напечатать в файл. Вот и предположим, что пользователь нашего модуля хочет печатать в файл.
Что будем делать?
Тут небольшое отступление: многие из читателей наверняка так сжились с ООП, что едва ли помнят, как думали раньше. А самое частое, самое прямолинейное решение, которое предлагают мне слушатели — просто создать новый метод, что-то вроде «возьми первые эн простых чисел, печатая в файл». Причём логика вывода в файл и логика подсчёта смешаны в одном методе.
Новый шаг — создать некоторого «писателя в файл», который будет очень похож на прежнего «писателя на консоль», а цель его жизни будет в том, чтобы отсоединить логику вывода от алгоритма подсчёта простых чисел. Итак, предположим, у нас есть:
public class FileWriter
{
public void WriteLine(string value)
{
// дописывает значение в конец файла
}
}
Тогда лобовое решение выглядит примерно так:
public class PrimeNumberCalculator
{
public int GetPrimeNumber(int n)
{
return GetPrimeNumbers(n).Last();
}
public int[] GetPrimeNumbers(int n)
{
var primeNumbers = new List();
int current = 2;
while (primeNumbers.Count != n)
{
if (primeNumbers.All(x => current % x != 0))
{
primeNumbers.Add(current);
new MyConsole().WriteLine(current.ToString());
}
current++;
}
return primeNumbers.ToArray();
}
public int[] GetPrimeNumbersToFile(int n)
{
var primeNumbers = new List();
int current = 2;
while (primeNumbers.Count != n)
{
if (primeNumbers.All(x => current % x != 0))
{
primeNumbers.Add(current);
new FileWriter().WriteLine(current.ToString());
}
current++;
}
return primeNumbers.ToArray();
}
}
Понятно, что так жить нельзя: хотя бы по двум причинам. Первая — мы безбожно дублируем код. Если придумаем какой-нибудь алгоритм получше, править нам придётся в двух местах. Если найдём и поправим ошибку, поправим её, вероятно, в одном месте, а во втором забудем. Словом код дублировать — так себе занятие. Причина вторая — мы позволяем пользователю выводить только на консоль и в файл. А если он хочет ещё куда-нибудь? Нам придётся менять наш код, компилировать и заливать новую версию, так что ли? Будем удовлетворять всем прихотям всех пользователей и создадим класс с двумя сотнями методов, которые умеют печатать куда угодно? Вздор, чушь. Так нельзя.
Возникает вопрос — что делать. А вообще — чего бы нам хотелось? Написать алгоритм подсчёта, который в нужный момент дёргает какого-то писателя. Причём любого. Какого угодно. Хоть писателя в файл, хоть писателя на консоль, хоть стукача в КГБ.
Ну и — ура! — у нас есть решение. Даже несколько (базовый класс, абстрактный класс, интерфейс). Я, в подобных случаях, предпочитаю интерфейс, понимая его как контракт. Класс, выполняющий интерфейс, обязуется выполнять условия договора (контракта), а мне, пользователю, неважно кто именно выполнит договор — лишь бы работа была сделана.
Итак, поехали:
public interface IWriter
{
void WriteLine(string value);
}
public class ConsoleWriter : IWriter
{
public void WriteLine(string value)
{
Console.WriteLine(value);
}
}
public class FileWriter : IWriter
{
public void WriteLine(string value)
{
// допишем значение в конец файла
}
}
public class PrimeNumberCalculator
{
public int GetPrimeNumber(int n, IWriter writer)
{
return GetPrimeNumbers(n, writer).Last();
}
public int[] GetPrimeNumbers(int n, IWriter writer)
{
var primeNumbers = new List();
int current = 2;
while (primeNumbers.Count != n)
{
if (primeNumbers.All(x => current % x != 0))
{
primeNumbers.Add(current);
writer.WriteLine(current.ToString());
}
current++;
}
return primeNumbers.ToArray();
}
}
class Program
{
static void Main()
{
Console.WriteLine("Input n");
var n = int.Parse(Console.ReadLine());
var writer = new ConsoleWriter();
new PrimeNumberCalculator().GetPrimeNumbers(n, writer);
}
}
Ну, что? Мы почти достигли своих целей. Пользователь может выбрать из набора писателей, которых мы ему предоставили или создать нового, указав, то тот выполняет интерфейс «писатель». Кстати сказать, заметьте как мы разрешаем зависимость от писателя — передаём объект в метод. Можно было бы, допустим, передать в конструктор класса, но это вопрос вкуса и целесообразности.
А вот такой ещё вопрос. Допустим, мы хотим соединить функциональность нескольких писателей. Скажем, мне понадобился «писатель на консоль и в файл». Или «писатель на консоль и в файл, и в веб (что бы это ни значило)». Что делать?
Первый порыв очевиден: создадим этого нового писателя, и пусть пишет во все три источника. Одна беда — мы опять дублируем код. То есть, мы уже написали всех трёх в отдельности, а теперь будем смешивать логику. Хорошо, это решается так:
public class WriterToConsoleAndFile : IWriter
{
public void WriteLine(string value)
{
new ConsoleWriter(value).WriteLine(value);
new FileWriter(value).WriteLine(value);
}
}
Всё работает, но есть недостатки. Мы уже говорили, что разрешать зависимости просто создавая объект — дурной тон. А что в этом дурного?
Да вот смотрите. Когда мы писали «новый писатель в консоль», мы уточняли, что именно в консоль, а ни куда-нибудь. Его нельзя было заметить на другой, не поменяв наш код, мы жёстко зависели именно от писателя в консоль. Мало того, от писателя в консоль созданного именно этим образом. Может быть, я хочу создать такой же класс, но изменить его настройки — сделаю по умолчанию синий фон и жёлтые буквы. То есть, я даже не пользуюсь полиморфизмом (то есть, не передаю писателя через интерфейс/базовый класс), но всё равно добиваюсь большей свободы выбора, больше возможностей предоставляю пользователю. Пусть он сам создаст объект и настроит его по собственному усмотрению. Моё же дело — пользоваться полученным инструментом. Итак, от слов — к делу.
public class WriterToConsoleAndFile : IWriter
{
private ConsoleWriter _consoleWriter;
private FileWriter _fileWriter;
public WriterToConsoleAndFile(
ConsoleWriter consoleWriter,
FileWriter fileWriter)
{
_consoleWriter = consoleWriter;
_fileWriter = fileWriter;
}
public void WriteLine(string value)
{
_consoleWriter.WriteLine(value);
_fileWriter.WriteLine(value);
}
}
Теперь остаётся заметить, что мы вовсе не пользуемся особыми свойствами «писателя в консоль» или «писателя в файл». Нам ни к чему здесь менять путь к файлу и вообще знать о его существовании. Мы не хотим менять цвет шрифта консоли — этим занимались не мы, а тот, кто создал экземпляр ConsoleWriter. А это означает, что нам хватит IWriter. Не будем просить больше, чем нужно. Вот:
public class WriterToConsoleAndFile : IWriter
{
private IWriter _consoleWriter;
private IWriter _fileWriter;
public WriterToConsoleAndFile(
IWriter consoleWriter,
IWriter fileWriter)
{
_consoleWriter = consoleWriter;
_fileWriter = fileWriter;
}
public void WriteLine(string value)
{
_consoleWriter.WriteLine(value);
_fileWriter.WriteLine(value);
}
}
Но теперь ведь нам в конструктор могут передать совсем других писателей. Не «в файл» и «в консоль», а что угодно. Что же, для наших целей это хорошо. Мы только что создали класс, который умеет объединить двух любых писателей. Куда там РСП!
Теперь остался всего один шаг: от двух — к произвольному числу. Вот он:
public class ManyWriters : IWriter
{
private IWriter _consoleWriter;
private IWriter _fileWriter;
public ManyWriters(IWriter[] writers)
{
_writers = writers;
}
public void WriteLine(string value)
{
foreach (var writer in _writers)
{
writer.WriteLine(value);
}
}
}
Ура, мы получили компоновщик.
Что ещё? Всё ещё не хватит? Да уже неплохо, но одно но. Теперь нам может быть не так-то просто собрать писателя. Создать и настроить одного, создать и настроить другого, потом объединить их при помощи компоновщика… Трудновато, правда? Можно упражнения ради создать файл с настройками, где будет написано, какими писателями мы хотим пользоваться.
И наш новый класс будет заниматься тем, что прочитает файл с настройками, создаст нужных писателей, проставит настройки, положит их в одного ManyWriters, закроет интерфейсом IWriter, чтобы никто вся остальная программа знать не знала о том, каким именно писателем мы пользуемся, и передаст его на выход… И это будет фабрика.