Ключевое слово 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 вы можете лучше объяснить компилятору, где его проверки важны и нужны, а где вы хотите их избежать. Всё это повышает общую корректность кода.

Комментарии (26)


  1. Tantrido
    30.10.2017 13:36
    +1

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


  1. sshmakov
    30.10.2017 13:53
    +1

    Определение mutable в классе зачастую излишне раскрывает внутреннюю жизнь класса. Если вы делаете класс для внешнего API, то изменения его внутренней структуры, той же технологии кеширования, как в статье, заставляет пользователей вашего API перекомпилировать свои программы.
    Поэтому мне больше по вкусу скрытая реализация внутренних данных, как это использовано в Qt.

    class ObjectPrivate;
    
    class QObject {
    private
        QObjectPrivate *d;
    public:
    // ... any const methods
    };


    1. ilynxy
      30.10.2017 14:23
      +1

      И тёмные стороны PIMPL имеет. Для стабилизации ABI подходит как нельзя лучше, но лишним разыменованием и использованием кучи, омрачает славный перформанс C++. Что в раз очередной сподвигает нас на предварительный анализ задачи и выбор правильных инструментов для её решения.


      1. sshmakov
        30.10.2017 14:39

        Использовать кучу правило или не использовать выбираете вы. Примеров есть кучи, где d на структуру, в контейнере выделенную один раз, указывает.

        Не, пусть Йоде будет йоднутое. Короче, вас никто не обязывает выделять на каждый объект отдельную структуру из кучи. В стеке, конечно, выделить так память не получится, но можно заранее отложить память в объекте-контейнере/фабрике под N объектов.

        Что да, в очередной…


    1. clayLain
      30.10.2017 15:19

      Только если для внешнего API.
      В остальных случаях это только утруднит написание и ухудшит производительность кода.
      Оба подхода нужны. Я просто считаю что такие решения должны приниматься из практических соображений, а не быть делом вкуса.


    1. qw1
      31.10.2017 22:01

      Поэтому мне больше по вкусу скрытая реализация внутренних данных, как это использовано в Qt.
      Возможность изменения реализации без перекомпиляции — это же совсем другая проблема, здесь не рассматриваемая.

      А решение проблемы спецификаторов const в публичном API на QObject, придётся так или иначе передать классу QObjectPrivate, и там снова думать — использовать mutable, или что-то другое.


      1. sshmakov
        31.10.2017 22:17

        Вы хорошо подумали?
        QObjectPrivate, во-первых, не публичный, его структура скрыта, во-вторых, не обязан иметь const методов.


        1. qw1
          31.10.2017 22:56

          Я почему-то подумал, что внутри const-метода нельзя вызывать не-const методы по указателю, который описан в классе:

          // QObject.h
          
          class QObject {
                  class QObjectPrivate;
                  QObjectPrivate *d;
          public:
                  int area() const;
          };
          
          // QObject.cpp
          
          class QObject::QObjectPrivate
          {
          public:
                  int area() { return 0; }
          };
          
          int QObject::area() const
          {
                  return d->area(); // вот здесь вызов не-const метода
          }

          Но этот пример у меня скомпилировался.

          Другое дело, если бы QObjectPrivate был не указателем, а членом класса QObject.
          // QObject.h
          
          class QObjectPrivate
          {
          public:
                  int area() { return 1; }
          };
          
          class QObject {
                  QObjectPrivate d;
          public:
                  int area() const;
          };
          
          // QObject.cpp
          
          int QObject::area() const
          {
                  return d.area(); // вот здесь ошибка
          }
          Я думал, что поведение должно быть аналогичным.


          1. sshmakov
            01.11.2017 09:55
            +1

            Да, в вызове d->area() значение указателя, лежащее в QObject, не меняется, следовательно для компилятора константность QObject соблюдена.
            Еще обратите внимание, что во втором случае, когда QObjectPrivate является членом класса QObject, вам пришлось перенести его определение в .h, т.е. раскрыть детали его реализации пользователям QObject. Что не всегда желательно.


  1. andy_p
    30.10.2017 15:52

    Не знаю, кем mutable забыт, но мне иногда приходится его использовать.


    1. vassabi
      30.10.2017 19:41

      для многопоточных данных\флагов — и сам делал, но вот как часто кто-то добавляет мутабельности константным методам класса?


      1. qw1
        31.10.2017 21:53

        Вы случайно с volatile не путаете?


  1. gasizdat
    31.10.2017 09:12

    В стаье незаслуженно забыт старый добрый const_cast. Он кстати позволяет скрыть текущие абстракции (в данном случае текущие конвенции) и перенести их из заголовков в реализацию, где им и место.


    1. qw1
      31.10.2017 21:54

      А не будет ли это UB?
      Если я сниму const с класса через const_cast, и вызову его не-const метод.


      1. Overlordff
        02.11.2017 22:26
        +2

        Только если у исходного объекта, у которого вызывается метод стоит квалификатор const. Если объект не константный, то его можно будет изменить через const_cast даже внутри const метода.


        1. qw1
          02.11.2017 23:50

          Интересно. Вот в таком примере:

          int GLOBAL;
          
          struct point 
          { 
                  int x;
          
                  int calc() const {
                          GLOBAL = x*17;
                          change();
                          return x*17;
                  }
          
                  void change() const {
                          point* self = const_cast<point*>(this);
                          self->x++;
                  }
          };

          компилятор не может полагаться на то, что значение 'x' у const-объекта *this не будет изменено при вызове ф-ции change()?

          В общем-то, играясь с разными опциями компилятора мне не удалось заставить его выполнить common sub-expression elimination, т.е. умножение на 17 он всегда честно делает 2 раза, независимо от опций оптимизации.


  1. slonopotamus
    31.10.2017 09:21

    Есть мнение, что const — довольно бесполезная, а местами даже вредная штука.

    Вредность в том что снимая const с одной функции, вы вынуждены бежать по всему коду и снимать const'ы со всего кода который эту функцию вызывает, и так рекурсивно.

    И наоборот, захотев добавить const на функцию, вы вынуждены обвешивать const'ами все что она вызывает. Причем в процессе вам может понадобиться потрогать код сторонней библиотеки — страдайте и обмазывайтесь const_cast'ами и mutable'ами.

    Теперь про бесполезность. const не дает никаких гарантий вызывающему коду, поэтому непонятно зачем он в сигнатуре.

    А не дает никаких гарантий потому что const имеет право изменять:

    1. Аргументы. Среди которых (возможно через множество промежуточных звеньев) может оказаться this, только уже без const'а

    2. Статические переменные. Среди которых опять же может оказаться this.

    3. mutable, рассмотренный в этой статье.

    4. И вообще, можно же взять и memset зафигачить. Что-то где-то поменяется.

    5. Самое печальное, что были прецеденты когда это непредумышленно происходило. И ложная надежда «const значит this не поменяется» только мешала.


    1. Antervis
      31.10.2017 09:37
      +1

      const нужен в качестве:
      1. дополнительной проверки корректности программ компилятором. Да, грязные хаки могут менять состояние константного объекта, но в корректном коде они не нужны и практически отсутствуют (за исключением mutable примитивов синхронизации). А вот очевидные ошибки/опечатки будут своевременно отловлены.
      2. дополнительной информации об API для вызывающей стороны.
      3. перегрузка по const/non-const позволяет проводить некоторые оптимизации.

      Причем в процессе вам может понадобиться потрогать код сторонней библиотеки — страдайте.

      для этого и существуют mutable/const_cast. Зловредную библиотеку можно вызывать через const-корректный адаптер


      1. slonopotamus
        31.10.2017 09:52

        1. Я вам целый список предоставил, способов которыми объект может измениться в результате вызова const-метода. Из них грязным хаком я бы назвал const_cast. Но вообще-то все эти механизмы входят в стандарт и UB, например, не вызывают.

        2. Какой дополнительной информации? Вы точно прочитали текст на который отвечаете?

        3. О, спасибо что напомнили. Запишите это тоже в минус: необходимость дублирования кода для const/не-const бесит, приводит к ошибкам, часто содержит в себе const_cast или является темплейтом (что привносит свои, темплейтные проблемы)

        4. Она не зловредная. Она просто не расставила const везде где это возможно (и никто этого не делает, вы в том числе). Хотя бы просто потому что у нее не было инструмента который бы это сделал.


        1. Antervis
          31.10.2017 11:02

          1. использование любого из этих методов не по прямому назначению подходит под правила дурного тона и является легитимным основанием отклонить ваш код на ревью. memcpy в с++ практически никогда не нужен.
          2. например, если в библиотеке есть функция, принимающая const T &val, она (с поправкой на профпригодность автора) не будет менять val.
          3. к каким ошибкам?
          4. всегда пишу const-корректный код. За последний год использовал const_cast единожды (и то в вызове сишной библиотеки). Мозг, глаза и руки — достаточный набор инструментов для написания корректных программ.

          Я уже не говорю про то, что иногда const-корректность необходима для достижения желаемого поведения

          п.с. есть еще noexcept, который, между прочим, может оптимизировать выполнение некоторых операций. Его вы тоже не рекомендуете к использованию только потому, что лень печатать?


    1. Ryppka
      01.11.2017 09:03
      +1

      Статические переменные. Среди которых опять же может оказаться this.

      Можно пример того, как this оказывается статической переменной?


      1. alexeykuzmin0
        02.11.2017 14:50
        +1

        Указатель со значением, равным this, вполне может лежать в статической переменной. Думаю, именно это и имелось в виду.


        1. Ryppka
          03.11.2017 13:42

          Понятно. Напоминает старый анекдот:
          Обитатель одной квартиры подал жалобу, что в его окно можно увидеть голых женщин в бане через дорогу. Приходит инспектор:
          — Да что Вы такое говорите, ничего не видно!
          — А возьмите бинокль и залезьте на шкаф!


  1. Antervis
    31.10.2017 09:21

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

    При написании const-корректных и многопоточных программ, mutable рано или поздно (скорее рано) понадобится. На мой взгляд, претендент на позицию с++ миддла не обязан знать все тонкости с++11/14/17, но базовые вещи из с++03 знать обязан


  1. Ryppka
    31.10.2017 12:03

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


  1. alexeykuzmin0
    02.11.2017 14:53
    +1

    Я бы отнес mutable (как и const_cast, memset, raw new, и многое другое) к разряду «грязных хаков». Да, ими вполне можно и даже нужно пользоваться, но относительно редко, и, как правило, лишь для цели достижения максимальной производительности и лишь в некоторых служебных местах. Поэтому любое их использование должно быть надежно инкапсулировано.