Опубликован: 05.11.2013 | Доступ: свободный | Студентов: 542 / 46 | Длительность: 11:51:00
Лекция 6:

Абстрактный тип данных

5.5. Инкапсуляция

Сокрытие реализации структуры и операций называется инкапсуляцией. Разработав и реализовав единожды некий скрытый тип, можно больше никогда не возвращаться к этой задаче, а стать пользователем своего модуля, просто рассматривая его как "черный ящик".

Использование скрытых типов имеет следующие преимущества:

  • детальное описание структуры данных не загромождает абстракцию;
  • компоненты скрытого типа недоступны импортеру.

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

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

В языке Си скрытый тип может быть описан только при помощи указателя. Название типа помещается в заголовочный файл для включения в модули-пользователи, а полное описание типа - в сам модуль (или его собственный h-файл). Конечно, такая схема реализации не защищает переменную скрытого типа от вмешательства, но при соблюдении определенных соглашений обеспечивает повышение уровня безопасности кода.

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

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

Чтобы задать тип в отдельном модуле, надо создать h-файл и поместить в него определение типа и заголовки методов работы с типом. Пусть "человек" будет хранить информацию о возрасте (другие поля опустим для упрощения). Тогда h-файл может выглядеть следующим образом:

  /* file: person.h */
  struct PEROBJ;
  typedef struct PEROBJ *PERSON;
  void createPerson(PERSON *p);
  void setAge(PERSON p, int newAge);
  int getAge(PERSON p);
  void deletePerson(PERSON *p);  
    

Мы задали тип как PERSON, который является указателем на PEROBJ. Описание и структура PEROBJ не раскрывается, поэтому из заголовочного файла нельзя увидеть структуру типа, а значит, из импортера не удастся работать с ней напрямую. Сразу определить тип как

typedef struct PERSON;  
    

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

В типе PERSON заданы операции создания и удаления. Они необходимы при выделении памяти под указатель и при ее высвобождении. Для всех скрытых типов приходится реализовывать такие операции. Более того, как уже было сказано, их необходимо обязательно использовать в импортере, иначе возникнет ошибка работы с указателем, для хранения значений которого не выделена память. К сожалению, большинство структурных языков программирования высокого уровня, и Си в том числе, не обладают никакими средствами помощи и контроля для создания/удаления переменных пользовательских типов. Это является одним из недостатков скрытых типов, который решается лишь в объектно-ориентированных языках.

Две другие приведенные операции - это операции-селекторы: задание и считывание возраста. Они необходимы, так как доступа к внутренней структуре нет и единственный способ работы с возрастом - это специальные методы. В функцию задания возраста переменная p передается по значению, несмотря на то, что в ней надо изменить значение объекта p. Дело в том, что сама переменная p уже является указателем, а изменения происходят в значении, на которое она указывает. А вот в функции создания переменная p передана по ссылке, так как при выделении памяти надо изменить само значение указателя (ссылки) p на новую область памяти.

Реализация типа помещается в с-файл, который может иметь вид:

/* file: person.c */
#include "person.h"
#include <malloc.h>
struct PEROBJ
{
  int age;
};
void createPerson(PERSON *p)
{
  *p = malloc(sizeof(struct PEROBJ));
}
void setAge(PERSON p, int newAge)
{
  (*p).age = newAge;
}
int getAge(PERSON p)
{
  return (*p).age;
}
void deletePerson(PERSON *p)
{
  free(*p);
}  
    

Здесь приводится описание типа PEROBJ, с которым происходит реальная работа и реализация указанных в h-файле функций. Чтобы работать с полями PERSON, приходится использовать конструкцию (*p).age, так как тип реализован указателем на структуру.

Для того чтобы работать с реализованным модулем, надо его импортировать, определить переменную типа PERSON и не забыть вызывать функцию создания перед работой с этой переменной;

/* file: test.c */
#include <stdio.h>
#include "person.h"
int main()
{
PERSON p1;
createPerson(&p1);
setAge(p1, 23);
printf("Hi, %d\n", getAge(p1));
deletePerson(&p1);
return 0;
}  
    

Саму переменную age из импортирующего модуля изменить нельзя - она недоступна. Это позволяет сохранять целостность типа. В импортере нельзя написать

(*р1).age = -10;  
    

и сделать переменную р1 некорректной.

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

В h-файл добавим два прототипа новых функций:

void setBirthDay(PERSON p, int day, int month, int year);
int getBirthDay(PERSON p);  
    

В c-файле изменим реализацию setAge и getAge и добавим две новых функции:

#include "person.h"
#include <malloc.h>
#include <time.h>
struct PEROBJ
{
  int day; /* 1-31 */
  int month; /* 1-12 */
  int year; /*1800 – 2100 */
};
void createPerson(PERSON *p)
{
  *p = malloc(sizeof(struct PEROBJ));
}
void setAge(PERSON p, int newAge)
{
  /* get current date in C format */
  time_t timer = time(NULL);
  /* convert our date to structure */
  struct tm *t = localtime(&timer);
  /* set current day */
  (*p).day = (*t).tm_mday;
  /* set current month (C format:0-11, our is:1-12)*/
  (*p).month = (*t).tm_mon+1;
  /* set birth year */
  /*(curent – newAge, */
  /*C format for year is: years from 1900) */
  (*p).year = (*t).tm_year+1900-newAge;
}
int getAge(PERSON p)
{
  /* get current date in C format */
  time_t timer = time(NULL);
  /* convert our date to structure */
  struct tm *t = localtime(&timer);
  return ((*t).tm_year+1900)-(*p).year;
}
void setBirthDay(PERSON p, int day, int month, int year)
{
  (*p).day = day;
  (*p).month = month;
  (*p).year = year;
}
int getBirthYear(PERSON p)
{
  return (*p).year;
}
void deletePerson(PERSON *p)
{
  free(*p);
}  
    

Интерпретация переменной age изменена, а ранее реализованные методы setAge и getAge по-прежнему возвращают возраст в годах. Все ранее написанные программы, которые использовали наш скрытый тип PERSON, продолжают работать, как и работали, никаких изменений в них не требуется. А все новые программы могут использовать дополнительные методы setBirthDay и getBirthYear.

Пример основной программы, работающей с новым модулем:

/* file: test2.c */
#include <stdio.h>
#include "person.h"
int main()
{
  PERSON p1;
  createPerson(&p1);
  setAge(p1, 23);
  printf("Hi, %d\n", getAge(p1));
  setBirthDay(p1, 23,05,1982);
  printf("Hi, %d\n", getAge(p1));
  printf("Hi, %d\n", getBirthYear(p1));
  deletePerson(&p1);
}  
    

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

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

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

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

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

Часто применяемые механизмы извещений об ошибках - это коды возврата функций, использование параметра функции для возврата кода/сообщения об ошибке или изменение значения глобальной переменной модуля. Реже используется дополнительная функция, которая при вызове сообщает, была ли ошибка при выполнении предпоследней функции (последней выполнялась она сама). Это далеко не полный перечень подходов к реализации механизма сообщений об ошибках.

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

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

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