1. Введение
Чистая функция — это функция, которая при одинаковых входных данных всегда возвращает одинаковый результат и не вызывает никаких побочных эффектов. Если проще: чистая функция не "портит" ничего вокруг себя и не заражается извне.
Функция считается чистой, если:
- Она только вычисляет значение на основе входных аргументов.
- Не изменяет внешних переменных или состояние программы.
- Не зависит от внешних переменных или состояний, которые могут измениться.
Два золотых правила чистоты
- Детерминированность: один и тот же вход — один и тот же выход.
- Нет побочных эффектов: функция не меняет ничего вне себя: ни файлов, ни глобальных переменных, ни интерфейса пользователя (привет, Console.WriteLine).
Не религия, а здравый смысл
Может показаться, что это какая-то академическая заморочка, но практика говорит обратное:
- Чистая функция предсказуема. Её легко тестировать — вы вызываете её с определёнными аргументами и получите определённый результат.
- Чистую функцию можно свободно "тасовать" в коде, не опасаясь, что что-то где-то сломается.
- В многоядерном мире чистая функция — это гарантия безопасности: её можно запускать параллельно без боязни гонок данных.
2. Примеры чистых и нечистых функций
Чистая функция: всё предсказуемо
// Чистая функция: не трогает ничего вне себя
int Add(int a, int b)
{
return a + b;
}
int Square(int x)
{
return x * x;
}
Вызовите Add(2, 3) хоть сто раз подряд — всегда будет 5. Скучно, но надёжно.
Нечистая функция: нарушает правила
// Нарушает чистоту: зависит от внешнего состояния (статической переменной)
int counter = 0;
int Increase()
{
counter++;
return counter;
}
Здесь вызов Increase() вернёт каждый раз новое значение — то есть уже не детерминированность.
// Нарушает чистоту: вызывает внешний эффект (печать на экран)
int AddAndPrint(int a, int b)
{
int sum = a + b;
Console.WriteLine(sum); // Побочный эффект!
return sum;
}
А как насчет случайности и времени?
Любая функция, использующая DateTime.Now или Random, уже не чиста:
// Не чистая!
int GetRandomNumber()
{
return new Random().Next();
}
Таблица различий
| Особенность | Чистая функция | Нечистая функция |
|---|---|---|
| Всегда одинаковый результат для одинаковых аргументов | Да | Нет |
| Побочные эффекты | Нет | Да |
| Зависимость от внешнего состояния | Нет | Да |
3. Неизменяемость данных: теория и практика
Неизменяемость (immutability) — это подход, когда объект нельзя изменить после создания. Если нужно новое значение — создаём новый объект.
Почему это важно?
- Приложение становится устойчивым к случайным ошибкам изменения данных.
- Отсутствуют тайные "утечки" изменений: если у вас есть какой-то объект, никто тайком не поменяет его поле.
- Неизменяемость — основа для многих автоматических оптимизаций и параллельных вычислений.
Простые примеры в C#
Неизменяемые типы в .NET
Строки (string) в C# неизменяемы! Каждый раз, когда вы делаете string.Concat(s, "world"), создаётся новая строка.
string s = "Hello";
string t = s;
s = s + " World";
Console.WriteLine(t); // t == "Hello"
Массивы и коллекции: mutable by default
int[] numbers = { 1, 2, 3 };
numbers[0] = 42; // Массив изменился!
Неизменяемость "на пальцах": компиляция кода
Вместо изменения существующего объекта/значения, возвращаем новый:
// Вместо этого:
void AddToList(List<int> list, int value)
{
list.Add(value); // Мутирует!
}
// Лучше так:
List<int> AddToList(List<int> list, int value)
{
var newList = new List<int>(list) { value }; // Новый список
return newList;
}
Иллюстрация: измени — не измени
flowchart LR
A[Исходный объект] --"мутация"--> B[Тот же объект, но другой внутри]
A --"неизменяемость"--> C[Новый объект]
4. Зачем это нужно в реальном C#-коде?
- В современном C# библиотеки вроде LINQ, Entity Framework и ASP.NET Core делают ставку на чистые функции и неизменяемость.
- Неизменяемость снижает количество "магических" багов, когда кто-то где-то затерял важное значение.
- Чистые функции позволяют легко использовать автоматическое тестирование (unit-тесты), потому что для теста нужно просто проверить вход и выход, не заботясь о внешнем мире.
Пример: работа со строками
string s = "Hello";
string newS = s.Replace("H", "J"); // s по-прежнему "Hello"; newS — "Jello"
Пример: LINQ и коллекции
Where, Select и другие методы возвращают новые коллекции, не трогая старые.
var numbers = new List<int> { 1, 2, 3, 4 };
var evenNumbers = numbers.Where(n => n % 2 == 0).ToList();
// numbers остался прежним!
Пример из реальной жизни: конфигурация через неизменяемые объекты
Многие современные API .NET используют неизменяемые объекты для конфигурации, например JsonSerializerOptions:
var options = new JsonSerializerOptions
{
WriteIndented = true
};
// Этот объект не меняется "по ходу пьесы", что повышает надёжность.
5. Типичные ошибки и ловушки
Скользкая дорожка начинается тогда, когда вы "случайно" мутируете данные в якобы "чистом" коде.
Часто так бывает с коллекциями: хотели вы фильтровать список, а в процессе поменяли исходный.
Или забыли, что метод, вроде List<T>.Add, меняет объект на месте.
Коварный пример:
List<int> DoubleTheNumbers(List<int> xs)
{
// Ошибка! Мутируем исходный список, возвращаем тот же объект.
foreach (var i in xs)
xs.Add(i * 2);
return xs;
}
Этот код вызовет даже ошибку выполнения (InvalidOperationException), потому что мы меняем коллекцию во время её обхода — классическая проблема мутации.
Правильно:
List<int> DoubleTheNumbers(List<int> xs)
{
var newList = new List<int>(xs.Select(x => x * 2));
return newList;
}
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ