Ключевое слово «mutable» в C++

в 10:31, , рубрики: c++, mutable, Блог компании Инфопульс Украина, Компиляторы, Программирование

Ключевое слово mutable относится к малоизвестным уголкам языка С++. В то же время оно может быть очень полезным, или даже необходимым в случае, если вы хотите строго придерживаться const-корректности вашего кода или писать лямбда-функции, способные изменять своё состояние.

Пару дней назад Eric Smolikowski написал в своём твиттере:
«Я часто спрашиваю программистов на собеседовании насколько хорошо (по 10-бальной шкале) они знают С++. Обычно они отвечают 8 или 9. И тогда я спрашиваю что такое „mutable“. Они не знают. :)»

Впечатления от таких вопросов и ответов у меня двоякие. С одной стороны, задавать подобные вопросы на собеседовании — дело бесполезное, это почти ничего не говорит о способностях интервьюируемого. Но, с другой стороны, ключевое слово mutable незаслуженно забыто многими программистами, а ведь оно может быть очень полезным в некоторых сценариях.

Const-корректность: семантическая константность против синтаксической константности

Когда мы пытаемся писать код, корректный с точки зрения использования понятия константности, то столкнёмся с тем, что семантическая неизменность не эквивалентна синтаксической неизменности. Другими словами, нам может понадобиться изменить состояние объекта (если этого требуют детали реализации), сохранив при этом видимое извне состояние объекта константным.

Изменение внутреннего состояния может требоваться по каким-то глубоко техническим причинам и это не должно быть заметно для внешних клиентов нашего класса. Но выбор у нас не большой — если мы используем ключевое слово const при объявлении метода, то компилятор не позволит нам изменить объект этого класса, даже если эти изменения никто вне класса и не заметит.

Кешированные данные

Хорошим примером может быть кеширование данных. Давайте посмотрим на вот этот класс полигона:

class Polygon {
  std::vector<Vertex> vertices;
public:
  Polygon(std::vector<Vertex> vxs = {}) 
    : vertices(std::move(vxs)) 
  {}

  double area() const {
    return geometry::calculateArea(vertices);
  }

  void add(Vertex const& vertex) {
    vertices.push_back(vertex);
  }

  //...
};

Давайте предположим, что geometry::calculateArea — это очень ресурсозатратная функция, которую мы не хотим вызывать каждый раз при вызове метода area(). Мы можем рассчитывать новую площадь при изменении полигона, но в некоторых сценариях это может быть настолько же (или даже больше) ресурсозатратно. Хорошим решением в данной ситуации может быть вычисление площади только тогда, когда это необходимо, с кеширование результата и очисткой кеша в случае изменения полигона.

class Polygon {
  std::vector<Vertex> vertices;
  double cachedArea{0};
public:
  //...

  double area() const {
    if (cachedArea == 0) {
      cachedArea = geometry::calculateArea(vertices);
    }
    return cachedArea;
  }

  void resetCache() {
    cachedArea = 0;
  }

  void add(Vertex const& vertex) {
    resetCache();
    vertices.push_back(vertex);
  }

  //...
};

Но, эй, погодите, не так быстро! Компилятор не даст вам провернуть подобный фокус, ведь метод area() помечен константным, а мы зачем-то пытаемся в нём изменять свойство cachedArea. Убрать const из объявления метода? Но тогда нас не поймут клиенты данного класса. Ведь area() — это простой геттер, данная функция точно не должна менять ничего в классе. Так почему же в её объявлении нет const?

Мьютексы

Ещё один пример это потокобезопасность с использованием мьютексов. Контейнер вершин в примере выше не потокобезопасен. Таким образом, в многопоточном приложении, где разные потоки будут совместно использовать данные одних и тех же полигонов, нам необходимо обеспечить безопасность доступа к этим данным:

class Polygon {
  std::vector<Vertex> vertices;
  std::mutex mutex;
public:
  Polygon(std::vector<Vertex> vxs = {}) 
    : vertices(std::move(vxs)) 
  {}

  double area() const {
    std::scoped_lock lock{mutex};
    return geometry::calculateArea(vertices);
  }

  void add(Vertex const& vertex) {
    std::scoped_lock lock{mutex};
    vertices.push_back(vertex);
  }

  //...
};

В данном случае компилятор снова начнёт жаловаться на метод area(), который бодро обещает быть константным, но сам (вот ведь негодяй!) пытается выполнить операцию mutex::lock(), которая меняет состояние мьютекса. То есть — мы не можем залочить константный мьютекс.

Получается, что мы снова не можем сделать метод area() константным и будем вынуждены либо отказаться от потокобезопасности, либо вводить в заблуждение клиентов нашего класса, избавляясь от const в объявлении метода. Из-за технических деталей реализации, которые не имеют совершенно никакого отношения к видимому извне состоянию объекта, нам приходится либо отказываться от части функционала, либо вводить в заблуждение пользователей класса.

Ключевое слово «mutable» спешит на помощь

Ключевое слово mutable существует в стандарте языка С++ именно для решения данного класса проблем. Его можно добавить к переменным членам класса для указания того, что данная переменная может изменяться даже в константном контексте. С использованием mutable решение обоих вышеуказанных примеров будет выглядеть вот так:

class Polygon {
  std::vector<Vertex> vertices;
  mutable double cachedArea{0};
  mutable std::mutex mutex;
public:
  //...

  double area() const {
    auto area = cachedArea;
    if (area == 0) {
      std::scoped_lock lock{mutex};
      area = geometry::calculateArea(vertices);
      cachedArea = area;
    }
    return area;
  }

  void resetCache() {
    assert(!mutex.try_lock());
    cachedArea = 0;
  }

  void add(Vertex const& vertex) {
    std::scoped_lock lock{mutex};
    resetCache();
    vertices.push_back(vertex);
  }

  //...
};

Изменяемые лямбда-функции

Есть и ещё один вариант применения ключевого слова mutable и он связан с сохранением состояния в лямбда-функциях. Обычно оператор вызова функции замыкания является константным. Другими словами — лямбда не может модифицировать переменные, захваченные по значению:

int main() {
  int i = 2;
  auto ok = [&i](){ ++i; }; //OK, i захватывается по ссылке
  auto err = [i](){ ++i; }; //Ошибка: попытка изменения внутренней копии i
  auto err2 = [x{22}](){ ++x; }; //Ошибка: попытка изменения внутренней переменной x
}

Но ключевое слово mutable может быть применено ко всей лямбда-функции, что сделает все её переменные изменяемыми:

int main() {
  int i = 2;
  auto ok = [i, x{22}]() mutable { i++; x+=i; };
}

Следует заметить, что в отличии от mutable-переменных в объявлении класса, мутабельные лямбда-функции должны использоваться относительно редко и очень аккуратно. Сохранение состояния между вызовами лямбда-функции может быть опасным и контринтуитивным.

Выводы

mutable — это не какой-то тёмный и покрытый пылью уголок языка С++, который вам никогда не понадобится. Это инструмент, который играет свою роль в чистом коде, и играет её тем лучше, чем чаще вы используете const и чем больше пытаетесь делать свой код безопасным и надёжным. С применением mutable вы можете лучше объяснить компилятору, где его проверки важны и нужны, а где вы хотите их избежать. Всё это повышает общую корректность кода.

Автор: Владимир

Источник

Поделиться

* - обязательные к заполнению поля