Ключевое слово mutable на C++ часто неправильно понимается или упускается из виду, однако в ряде сценариев оно очень важно.

Подробно изучим их вместе с преимуществами mutable и потенциальными ошибками.

Что такое mutable на C++?

Это ключевое слово для изменения переменной-члена константного объекта. Посредством mutable указывается, что конкретный член данных константного объекта изменяется, не сказываясь на неизменяемости объекта.

Начнем с простого примера:

class Example {
public:
int getValue() const {
return value;
}

void setValue(int newValue) const {
value = newValue; // Ошибка: нельзя изменить член в константной функции
}

private:
int value;
};

В этом случае setValue не скомпилируется, потому что это попытка изменить value в константной функции. Если же объявить value как mutable:

class Example {
public:
int getValue() const {
return value;
}

void setValue(int newValue) const {
value = newValue; // Теперь получается
}

private:
mutable int value;
};

Теперь setValue скомпилируется и его работа обойдется без неожиданностей, хотя это константная функция.

Когда используется mutable

Ключевое слово mutable особенно кстати в сценариях:

  1. Кэширования.
  2. Отложенных вычислений.
  3. Синхронизации потоков.
  4. Сохранения логической неизменяемости.

Рассмотрим каждый сценарий с практическими примерами.

Кэширование с mutable

Кэширование  —  типичный сценарий для mutable. Возьмем класс, которым выполняется дорогостоящее вычисление:

class DataProcessor {
public:
DataProcessor(const std::vector<int>& data) : data_(data) {}

int getSum() const {
if (!cachedSum_) {
cachedSum_ = std::accumulate(data_.begin(), data_.end(), 0);
}
return *cachedSum_;
}

private:
std::vector<int> data_;
mutable std::optional<int> cachedSum_;
};

В этом примере cachedSum_ объявлена как mutable и, хотя это константная функция, изменяется в getSum(). При первом вызове getSum() вычисляется и кэшируется сумма. При дальнейших  —  возвращается кэшированное значение, так повышается производительность при сохранении неизменяемости объекта.

Отложенные вычисления

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

class LazyString {
public:
LazyString(const std::string& str) : original_(str) {}

const std::string& getUpperCase() const {
if (!upperCase_) {
upperCase_ = original_;
std::transform(upperCase_->begin(), upperCase_->end(), upperCase_->begin(), ::toupper);
}
return *upperCase_;
}

private:
std::string original_;
mutable std::optional<std::string> upperCase_;
};

Здесь upperCase_ является mutable и вычисляется только при вызове getUpperCase(). Так экономятся память и вычислительное время, если версия в верхнем регистре останется невостребованной.

Синхронизация потоков

В многопоточном коде mutable используется с примитивами синхронизации:

#include <mutex>

class ThreadSafeCounter {
public:
int getValue() const {
std::lock_guard<std::mutex> lock(mutex_);
return value_;
}

void increment() {
std::lock_guard<std::mutex> lock(mutex_);
++value_;
}

private:
int value_ = 0;
mutable std::mutex mutex_;
};

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

Сохранение логической неизменяемости

Иногда объекту нужно изменить свое внутреннее состояние, не затрагивая его логического состояния. И здесь приходится кстати mutable:

class BigData {
public:
BigData(const std::vector<int>& data) : data_(data) {}

int get(size_t index) const {
if (index >= data_.size()) {
throw std::out_of_range("Index out of range");
}
++accessCount_;
return data_[index];
}

size_t getAccessCount() const {
return accessCount_;
}

private:
std::vector<int> data_;
mutable size_t accessCount_ = 0;
};

В этом примере accessCount_ является mutable, благодаря чему отслеживается, сколько раз осуществлялся доступ к данным без изменения логического состояния объекта. Эта информация полезна при отладке или оптимизации.

Потенциальные ошибки

mutable  —  полезный функционал, но применять его нужно с умом. Вот потенциальные ошибки и рекомендации:

  1. Потокобезопасность. Члены класса с mutable не являются потокобезопасными автоматически, в многопоточном коде необходимо обеспечить корректную синхронизацию:
class UnsafeCounter {
public:
void increment() const {
++count_; // Это не потокобезопасно
}

private:
mutable int count_ = 0;
};

2. Логическая неизменяемость. Когда изменяется член класса с mutable, не должно изменяться логическое состояние объекта:

class BadExample {
public:
void setData(int data) const {
data_ = data; // Этим нарушается логическая неизменяемость
}

private:
mutable int data_;
};

3. Ясность кода. mutable используется только при необходимости, злоупотребление им чревато запутанным и сложным в сопровождении кодом.

4. Документация. При использовании mutable рекомендуется документировать, почему он необходим:

class GoodExample {
public:
int getData() const {
return data_;
}

private:
// «mutable» используется здесь для кэширования с целью повышения производительности
mutable int data_ = 0;
};

Реальный сценарий: конфигурация с отложенной загрузкой

Рассмотрим пример с mutable посложнее  —  класс конфигурации, которым выполняется отложенная загрузка данных из файла:

#include <fstream>
#include <string>
#include <unordered_map>
#include <stdexcept>

class Configuration {
public:
Configuration(const std::string& filename) : filename_(filename) {}

std::string getValue(const std::string& key) const {
loadConfigIfNeeded();
auto it = config_.find(key);
if (it == config_.end()) {
throw std::runtime_error("Key not found: " + key);
}
return it->second;
}

private:
void loadConfigIfNeeded() const {
if (!isLoaded_) {
std::ifstream file(filename_);
if (!file) {
throw std::runtime_error("Unable to open config file: " + filename_);
}

std::string line;
while (std::getline(file, line)) {
size_t delimiterPos = line.find('=');
if (delimiterPos != std::string::npos) {
std::string key = line.substr(0, delimiterPos);
std::string value = line.substr(delimiterPos + 1);
config_[key] = value;
}
}

isLoaded_ = true;
}
}

std::string filename_;
mutable bool isLoaded_ = false;
mutable std::unordered_map<std::string, std::string> config_;
};

Здесь isLoaded_ и config_ являются mutable, поэтому из константной функции getValue() вызывается loadConfigIfNeeded(). Конфигурация загрузится, только когда впервые понадобится. Если же останется невостребованной, будут сэкономлены память и время запуска.

Проиллюстрируем примером:

int main() {
const Configuration config("settings.conf");

try {
std::cout << "Log level: " << config.getValue("log_level") << std::endl;
std::cout << "Max connections: " << config.getValue("max_connections") << std::endl;
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
}

return 0;
}

Таким образом объект Configuration получается константным, при этом отложенная загрузка его данных по-прежнему выполняется. Это потокобезопасно, притом что не осуществляется конкурентный доступ, эффективно и с сохранением логической неизменяемости.

Заключение

Ключевое слово mutable на C++  —  это инструмент, при корректном применении которого пишется более эффективный и логически непротиворечивый код. Особенно кстати оно приходится при кэшировании, отложенной загрузке, синхронизации потоков и сохранении логической неизменяемости.

Однако важно использовать mutable с умом: злоупотребление чревато запутанным кодом и потенциальными проблемами потокобезопасности.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи ryan: C++ Mutable: Comprehensive Guide

Предыдущая статьяПостроение комплексных конвейеров сборки вокруг Kubernetes
Следующая статьяПакеты NPM: что это такое, откуда они взялись и когда их использовать