« К оглавлению
‹ Абстрактные типы данных ↟ Вверх Конструкторы ›

Сокрытие данных и инкапсуляция

В предыдущей части мы создали структуру, которая представляет в программах дробные числа. Почти сразу же стало очевидно, что одной из проблем этой структуры является полный доступ к ее полям из любого места в программе. Это позволяет перезаписывать значения числителя и знаменателя в любой момент, в обход всех правил, которые были ранее установлены для создания рациональных чисел. Это также противоречит сути нашего типа данных, который является абстрактным согласно установленному выше дизайну. Из того, что это абстрактный тип данных следует, что пользователь не должен изменять эти поля напрямую. А это значит, что их нужно от пользователя как-то скрыть.

В С++ для этого есть специальный механизм[1]. Этот механизм позволяет запретить прямое изменение полей структуры одним из двух способов. Первый способ заключается в добавлении специального слова private в структуру, непосредственно над полями, которые необходимо скрыть:


struct rational
{
private:
    int p;
    int q;
};

Обратите внимание на двоеточие после данного слова, это напоминает ярлыки в С, которые можно использовать для возврата к нужной строке в программе. Ярлык private делит структуру на две области, все что находится над ним доступно вне структуры, а все что под ним - скрыто и доступно только внутри структуры. Сейчас в нашей структуре ничего кроме двух полей нет, поэтому поля p и q не доступны нигде.

Второй способ достичь такого же эффекта - использовать ключевое слово class вместо struct в определении структуры:


class rational
{
    int p;
    int q;
};

Оба подхода дают одинаковый результат, поэтому на практике можно использовать любой из них, но, как правило, struct используется когда какие-то поля с данными все-таки планируется сделать открытыми для изменений, а class - когда поля должны быть скрыты от внешнего воздействия[2].

Независимо от того, какой метод мы выберем, эффект будет один: программа перестанет работать, так как создать “хорошую” переменную типа rational - ни напрямую, ни с помощью функции cons - будет нельзя. Доступ к полям p и q теперь возможен только внутри структуры, а никакого другого кода там нет, поэтому и сами поля будут оставаться неизменными в течение жизни созданного объекта такого типа. Если бы мы писали нашу программу в строгом соответствии с правилами языка С, на этом история типа rational бы и закончилась, так как никакой пользы в программах такие объекты бы не приносили. Собственно, в С нет механизмов сокрытия данных, потому что С создавался совсем для других целей (для написания ядра операционной системы Unix, например[3]). Конечно, добавив такую возможность в C++, его авторы добавили и способы работы с внутренними полями. Хорошая новость заключается в том, что нам почти ничего не надо менять для этого. Достаточно взять функцию cons и перенести ее внутрь самой структуры.


#include <assert.h>

struct rational
{
    static rational cons(const int x, const int y)
    {
        assert(y != 0);
        rational r;
        if (x == 0)
        {
            r.p = 0;
            r.q = 0;
        }
        else if (y < 0)
        {
            r.p = -x;
            r.q = -y;
        }
        else
        {
            r.p = x;
            r.q = y;
        }
        return r;
    }
private:
    int p;
    int q;
};

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

В С++ это слово приобретает новые свойства, которые легче будет понять на конкретном примере. Допустим, что этого слова там нет. Попробуем теперь создать новую переменную, используя эту функцию. Первый же вопрос, который встанет перед вами: “как это сделать?”, как вызвать функцию которая находится внутри какой-то структуры? Мы знаем, что к именам структуры необходимо обращаться либо через оператор-точку, либо через оператор-стрелочку, в зависимости от типа данных объекта. Так как у нас объект типа rational, вызов функции мог бы выглядеть так:


rational b;
b.cons(20, 50);

Интуиция в этом случае оказалась верной. Авторы С++ не стали изобретать велосипед и сохранили привычный синтаксис языка С для этого случая. Но при этом мы немного потеряли в лаконичности, опять вернувшись к тому, что необходимо сначала создать ничем не инициализированный объект, а потом использовать функцию, чтобы изменить его значения. Более того, это даже вводит в заблуждение, потому что такой вызов функции не изменит саму переменную b. Функция вернет копию нового объекта, который надо будет сохранить по адресу b с помощью присваивания: rational b = b.cons(20, 50);. Согласитесь, что такая запись выглядит немного странно. Ключевое слово static в примере выше как раз и нужно для того, чтобы исключить двоякое чтение кода. Почему b находится и справа, и слева от оператора присваивания? Что происходит раньше: создание переменной или вызов функции? Эту запись стоило бы сделать более понятной.

Правда, тут всплывает другой нюанс. Согласно правилам языка статические функции ничего не знают об объектах того типа, которому принадлежат. Они знают только внутреннюю структуру самого типа данных. Как следствие, форма записи b.cons или b->cons для статических функций не нужна. Но и запись вида rational b = cons(10, 50); невозможна, потому что невозможно определить где в файле находится идентификатор cons. И здесь нам на помощь приходит оператор ::, указывающий на область видимости или scope (англ.). В нашем примере область видимости для имени функции cons определена границами структуры rational, а значит и обращаться к функции надо посредством имени структуры. rational b = rational::cons(10, 50);. Здесь мы явно указываем на то, что функцию под таким именем нужно искать где-то внутри структуры rational. Наконец-то мы получили желанную форму записи, при этом защитив другие поля структуры от внешнего воздействия. Осталось обсудить возможные альтернативы такого подхода.

Один из самых распространенных способов - создать специальные функции внутри структуры, которые дают возможность изменять поля структуры при соблюдении определенных правил. Такие функции называются “мутаторами” (от англ. слова mutate - “изменять”) или “сеттерами” (от англ. слова set - “устанавливать”).


#include <assert.h>

struct rational
{
    void set_p(int x)
    {
        p = x;
    }
    void set_q(int x)
    {
        assert(x != 0);
        q = x;
    }
private:
    int p;
    int q;
};

rational c;
c.set_p(5);
c.set_q(10); 

Фрагмент выше может показаться знакомым. Как и в самом первом примере, здесь объект под именем с не конструируется с этими значениями. Значения задаются уже после создания переменной. Это принципиальный момент: в одном случае объект создается с уже заданными полями и может быть использован в других контекстах, в другом случае объект необходимо как-то инициализировать во время создания – т.о. мы увидели два разных подхода к созданию объектов. Возникает закономерный вопрос, “какой из этих способов лучше?”.

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

С этого момента мы введем два новых термина. Факт группирования данных и операций над ними внутри какой-то структуры мы будем называть инкапсуляцией. Совокупность всех открытых операций (т.е. функций, которые не скрыты ярлыком private), определенных внутри структуры, мы будем называть ее интерфейсом.


 

Упражнения

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

В предыдущей части мы разработали функцию, которая сокращает дробь. Очевидно, что если изменить значение числителя или знаменателя после сокращения, сокращать дробь придется еще раз. Было бы лучше, если бы это происходило само собой, как при конструировании объекта, так и при любых изменениях числителя или знаменателя. Это означает, что функцию reduce можно использовать по-разному. Можно сделать ее внешней нашей структуре, используя сеттеры и геттеры* для ее реализации. Можно сделать ее внутренней скрытой функцией и вызывать в нужных местах других функций. Наконец, можно сделать ее внутренней статической функцией, включив ее в интерфейс структуры, чтобы ее можно было вызывать в любом месте программы. Настоятельно рекомендуется попробовать самостоятельно реализовать все три варианта. Для проверки одного из них:


#include <assert.h>

int main(int argc, char const *argv[])
{
    rational a = rational::cons(10, 50);
    a.set_p(15);
    a.set_q(45);
    rational::reduce(a);
    assert(1 == a.get_p() && 3 == a.get_q());
        
    rational b = rational::cons(10, 50);
    assert(1 == b.get_p() && 5 == b.get_q());
}

Другим вариантом решения этой проблемы может быть полный отказ от сеттеров. Тогда остается только один способ работы с рациональными числами – создание новых объектов каждый раз, когда какое-то значение должно поменяться. Это значит, что придется переписать и функцию reduce. Здесь из соображений краткости для построения нового объекта может подойти функция cons. Самые внимательные из вас помнят, что внутри cons как раз используется функция reduce, а это чревато бесконечной взаимной рекурсией.

Данный подход менее эффективен в контексте языка C, потому что делает несколько дополнительных вызовов функций и создает новые объекты, без которых можно было бы обойтись. Но в других языках программирования (например, в Haskell[4] или OCaml[5] запрет на изменение существующих переменных – “мутации” – часто является обязательным, поэтому такой подход к решению задач надо иметь ввиду.

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


 

Следующее упражнение призвано развенчать некоторые мифы, связанные с классами в C++. Для его выполнения понадобится некоторое знание указателей и того, как структурируется виртуальная память процесса.

Задача заключается в следующем. Необходимо создать переменную типа rational, а затем изменить ее скрытые поля данных p и q, не меняя структуру rational и не используя сеттеры. Подумайте, как это может быть связано с указателями, с тем, как они работают в C. Если вы не смогли достичь результата, поищите информацию о технике type punning.

О чем говорит этот пример? Во-первых, о том, что ярлык private не дает какой-то “железной” защиты данных, которые под ним скрываются. Во-вторых, о том, что сокрытие данных и инкапсуляция, в основном, призваны не защитить данные от изменения, а защитить программистов от соблазна изменять данные. Эта мысль проходит красной нитью через основание всех принципов объектно-ориентированного программирования. Не стоит забывать, что в конечном итоге вы пишете программы для компьютера, а не для других программистов, и все стилистические решения в ваших программах должны, в первую очередь, диктоваться требованиями компонентов компьютера, для которого они пишутся.


 

#include <assert.h>

int main(int argc, char const *argv[])
{
    // 1-е упражнение
    rational a = rational::cons(10, 50);
    assert(1 == a.get_p() && 5 == a.get_q());

    a.set_p(15);
    a.set_q(45);
    a = rational::reduce(a);
    assert(1 == a.get_p() && 3 == a.get_q());

    rational b = rational::cons(20, 50);
    assert(2 == b.get_p() && 5 == b.get_q());

    // 2-е упражнение
    //решение с type punning здесь
    assert(5 == b.get_p() && 6 == b.get_q());
}

 

Сноски

*Getter - (от слова get, англ.) внутренняя функция какой-либо структуры данных, которая возвращает значение внутренних полей структуры без возможности их последующего изменения.


 

Источники

  1. Stroustrup, B., Sutter, H., “C++ Core Guidelines”, International Standardization Organization C++, 2022, isocpp.github.io/CppCoreGuidelines/
  2. Если говорить конкретней, слово “класс” используется в ситуациях, когда необходимо подчеркнуть особенность способа управления процессов в программе, когда классы используются для создания новых типов данных, которые управляют выполнением команд через обмен сообщениями между объектами с помощью их методов; Goldberg, A., Kay, A., “The Smalltalk World and Its Primitives”, Smalltalk-72 instruction manual, Palo Alto Research Center, Xerox, p.48, March, 1976.
  3. Ritchie, D.M., “The Development of the C Language”, History of Programming Languages, 2nd ed., ACM Press, 1996.
  4. Haskell programming language, haskell.org
  5. OCaml programming language, ocaml.org

 

Copyright © 2022 Брынзан, Л.В.
GNU General Public License
“Commons Clause” License Condition v1.0
GPL Attribution-ShareAlike 4.0 International