Encapsulation and Protection Attributes
All of the structs and classes that we have defined so far have all been accessible from the outside.
Let's consider the following struct:
enum Gender { female, male } struct Student { string name; Gender gender; }
The members of that struct is freely accessible to the rest of the program:
auto student = Student("Tim", Gender.male); writefln("%s is a %s student.", student.name, student.gender);
Such freedom is a convenience in programs. For example, the previous line has been useful to produce the following output:
Tim is a male student.
However, this freedom is also a liability. As an example, let's assume that perhaps by mistake, the name of a student object gets modified in the program:
student.name = "Anna";
That assignment may put the object in an invalid state:
Anna is a male student.
As another example, let's consider a School
class. Let's assume that this class has two member variables that store the numbers of the male and female students separately:
class School { Student[] students; size_t femaleCount; size_t maleCount; void add(Student student) { students ~= student; final switch (student.gender) { case Gender.female: ++femaleCount; break; case Gender.male: ++maleCount; break; } } override string toString() const { return format("%s female, %s male; total %s students", femaleCount, maleCount, students.length); } }
The add()
member function adds students while ensuring that the counts are always correct:
auto school = new School; school.add(Student("Lindsey", Gender.female)); school.add(Student("Mark", Gender.male)); writeln(school);
The program produces the following consistent output:
1 female, 1 male; total 2 students
However, being able to access the members of School
freely would not guarantee that this consistency would always be maintained. Let's consider adding a new element to the students
member, this time directly:
school.students ~= Student("Nancy", Gender.female);
Because the new student has been added to the array directly, without going through the add()
member function, the School
object is now in an inconsistent state:
1 female, 1 male; total 3 students
Encapsulation
Encapsulation is a programming concept of restricting access to members to avoid problems similar to the one above.
Another benefit of encapsulation is to eliminate the need to know the implementation details of types. In a sense, encapsulation allows presenting a type as a black box that is used only through its interface.
Additionally, preventing users from accessing the members directly allows changing the members of a class freely in the future. As long as the functions that define the interface of a class is kept the same, its implementation can be changed freely.
Encapsulation is not for restricting access to sensitive data like a credit card number or a password, and it cannot be used for that purpose. Encapsulation is a development tool: It allows using and coding types easily and safely.
Protection attributes
Protection attributes limit access to members of structs, classes, and modules. There are two ways of specifying protection attributes:
- At struct or class level to specify the protection of every struct or class member individually.
- At module level to specify the protection of every feature of a module individually: class, struct, function, enum, etc.
Protection attributes can be specified by the following keywords. The default attribute is public
.
-
public
: Specifies accessibility by any part of the program without any restriction.An example of this is
stdout
. Merely importingstd.stdio
makesstdout
available to every module that imported it. -
private
: Specifies restricted accessibility.private
class members and module members can only be accessed by the module that defines that member.Additionally,
private
member functions cannot be overridden by subclasses. -
package
: Specifies package-level accessibility.A feature that is marked as
package
can be accessed by all of the code that is a part of the same package. Thepackage
attribute involves only the inner-most package.For example, a
package
definition that is inside theanimal.vertebrate.cat
module can be accessed by any other module of thevertebrate
package.Similar to the
private
attribute,package
member functions cannot be overridden by subclasses. -
protected
: Specifies accessibility by derived classes.This attribute extends the
private
attribute: Aprotected
member can be accessed not only by the module that defines it, but also by the classes that inherit from the class that defines thatprotected
member.
Additionally, the export
attribute specifies accessibility from the outside of the program.
Definition
Protection attributes can be specified in three ways.
When written in front of a single definition, it specifies the protection attribute of that definition only. This is similar to the Java programming language:
private int foo; private void bar() { // ... }
When specified by a colon, it specifies the protection attributes of all of the following definitions until the next specification of a protection attribute. This is similar to the C++ programming language:
private: // ... // ... all of the definitions here are private ... // ... protected: // ... // ... all of the definitions here are protected ... // ...
When specified for a block, the protection attribute is for all of the definitions that are inside that block:
private { // ... // ... all of the definitions here are private ... // ... }
Module imports are private by default
A module that is imported by import
is private to the module that imports it. It would not be visible to other modules that import it indirectly. For example, if a school
module imports std.stdio
, modules that import school
cannot automatically use the std.stdio
module.
Let's assume that the school
module starts by the following lines:
module school.school; import std.stdio; // imported for this module's own use... // ...
The following program cannot be compiled because writeln
is not visible to it:
import school.school; void main() { writeln("hello"); // ← compilation ERROR }
std.stdio
must be imported by that module as well:
import school.school; import std.stdio; void main() { writeln("hello"); // now compiles }
Sometimes it is desired that a module presents other modules indirectly. For example, it would make sense for a school
module to automatically import a student
module for its users. This is achieved by marking the import
as public
:
module school.school; public import school.student; // ...
With that definition, modules that import school
can use the definitions that are inside the student
module without needing to import it:
import school.school; void main() { auto student = Student("Tim", Gender.male); // ... }
Although the program above imports only the school
module, the student.Student
struct is also available to it.
When to use encapsulation
Encapsulation avoids problems similar to the one we have seen in the introduction section of this chapter. It is an invaluable tool to ensure that objects are always in consistent states. Encapsulation helps preserve struct and class invariants by protecting members from direct modifications by the users of the type.
Encapsulation provides freedom of implementation by abstracting implementations away from user code. Otherwise, if users had direct access to for example School.students
, it would be hard to modify the design of the class by changing that array e.g. to an associative array, because this would affect all user code that has been accessing that member.
Encapsulation is one of the most powerful benefits of object oriented programming.
Example
Let's define the Student
struct and the School
class by taking advantage of encapsulation and let's use them in a short test program.
This example program will consist of three files. As you remember from the previous chapter, being parts of the school
package, two of these files will be under the "school" directory:
- "school/student.d": The
student
module that defines theStudent
struct - "school/school.d": The
school
module that defines theSchool
class - "deneme.d": A short test program
Here is the "school/student.d" file:
module school.student; import std.string; import std.conv; enum Gender { female, male } struct Student { package string name; package Gender gender; string toString() const { return format("%s is a %s student.", name, to!string(gender)); } }
The members of this struct are marked as package
to enable access only to modules of the same package. We will soon see that School
will be accessing these members directly. (Note that even this should be considered as violating the principle of encapsulation. Still, let's stick with the package
attribute in this example program.)
The following is the "school/school.d" module that makes use of the previous one:
module school.school; public import school.student; // 1 import std.string; class School { private: // 2 Student[] students; size_t femaleCount; size_t maleCount; public: // 3 void add(Student student) { students ~= student; final switch (student.gender) { // 4a case Gender.female: ++femaleCount; break; case Gender.male: ++maleCount; break; } } override string toString() const { string result = format( "%s female, %s male; total %s students", femaleCount, maleCount, students.length); foreach (i, student; students) { result ~= (i == 0) ? ": " : ", "; result ~= student.name; // 4b } return result; } }
school.student
is being imported publicly so that the users ofschool.school
will not need to import that module explicitly. In a sense, thestudent
module is made available by theschool
module.- All of the member variables of
School
are marked as private. This is important to help protect the consistency of the member variables of this class. - For this class to be useful, it must present some member functions.
add()
andtoString()
are made available to the users of this class. - As the two member variables of
Student
have been marked aspackage
, being a part of the same package,School
can access those variables.
Finally, the following is a test program that uses those types:
import std.stdio; import school.school; void main() { auto student = Student("Tim", Gender.male); writeln(student); auto school = new School; school.add(Student("Lindsey", Gender.female)); school.add(Student("Mark", Gender.male)); school.add(Student("Nancy", Gender.female)); writeln(school); }
This program can use Student
and School
only through their public interfaces. It cannot access the member variables of those types. As a result, the objects would always be consistent:
Tim is a male student. 2 female, 1 male; total 3 students: Lindsey, Mark, Nancy
Note that the program interacts with School
only by its add()
and toString()
functions. As long as the interfaces of these functions are kept the same, changes in their implementations would not affect the program above.