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

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

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

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


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

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

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


class rational {
    int p;
    int q;
};    

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

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


class rational {
public:
    static rational cons(int x, 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);  

Интуиция в этом случае оказалась верной. Авторы С++ не стали изобретать велосипед и сохранили привычный синтаксис языка C для этого случая. Но при этом код немного потерял в лаконичности: необходимо сначала создать ничем не инициализированный объект, а потом использовать функцию, чтобы изменить его значения. Более того, это даже вводит в заблуждение, потому что такой вызов функции не изменит саму переменную b. Функция вернет копию нового объекта, который надо будет сохранить в b с помощью присваивания:


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

Согласитесь, что такая запись выглядит странно. Ключевое слово static в примере выше как раз и нужно для того, чтобы исключить двоякое чтение кода. Почему b находится и справа, и слева от оператора присваивания? Что происходит раньше: создание переменной или вызов функции? Эту запись стоило бы сделать более понятной.

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


rational b = rational::cons(10, 50);

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

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


class rational {
public:
    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), определенных внутри класса, мы будем называть ее интерфейсом.


 

Упражнения

1

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

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

2

Это упражнение призвано развенчать некоторые мифы, связанные с классами в С++. Для его выполнения понадобится некоторое знание указателей и того, как структурируется виртуальная память процесса в языке С. Необходимо создать переменную типа rational, а затем изменить ее скрытые поля данных p и q, не меняя класс rational и не используя сеттеры. Подумайте, как это может быть связано с указателями и с тем, как они работают в С.


 

Сноски

1Если говорить конкретней, слово “класс” используется в ситуациях, когда необходимо подчеркнуть особенность способа управления процессов в программе, когда классы используются для создания новых типов данных, которые управляют выполнением команд через обмен сообщениями между объектами с помощью их методов [Goldberg].

2С этого момента в тексте будет использоваться слово class, все области доступа будут помечены соответствующей меткой.

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


 

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