Properties
Properties allow using member functions like member variables.
We are familiar with this feature from slices. The length
property of a slice returns the number of elements of that slice:
int[] slice = [ 7, 8, 9 ]; assert(slice.length == 3);
Looking merely at that usage, one might think that .length
is implemented as a member variable:
struct SliceImplementation { int length; // ... }
However, the other functionality of this property proves that it cannot be a member variable: Assigning a new value to the .length
property actually changes the length of the slice, sometimes by adding new elements to the underlying array:
slice.length = 5; // The slice now has 5 elements assert(slice.length == 5);
Note: The .length
property of fixed-length arrays cannot be modified.
The assignment to .length
above involves more complicated operations than a simple value change: Determining whether the array has capacity for the new length, allocating more memory if not, and moving the existing elements to the new place; and finally initializing each additional element by .init
.
Evidently, the assignment to .length
operates like a function.
Properties are member functions that are used like member variables.
Calling functions without parentheses
As has been mentioned in the previous chapter, when there is no argument to pass, functions can be called without parentheses:
writeln();
writeln; // Same as the previous line
This feature is closely related to properties because properties are used almost always without parentheses.
Property functions that return values
As a simple example, let's consider a rectangle struct that consists of two members:
struct Rectangle { double width; double height; }
Let's assume that a third property of this type becomes a requirement, which should provide the area of the rectangle:
auto garden = Rectangle(10, 20); writeln(garden.area);
One way of achieving that requirement is to define a third member:
struct Rectangle { double width; double height; double area; }
A flaw in that design is that the object may easily become inconsistent: Although rectangles must always have the invariant of "width * height == area", this consistency may be broken if the members are allowed to be modified freely and independently.
As an extreme example, objects may even begin their lives in inconsistent states:
// Inconsistent object: The area is not 10 * 20 == 200. auto garden = Rectangle(10, 20, 1111);
A better way would be to represent the concept of area as a property. Instead of defining an additional member, the value of that member is calculated by a function named area
, the same as the concept that it represents:
struct Rectangle { double width; double height; double area() const { return width * height; } }
Note: As you would remember from the const ref
Parameters and const
Member Functions chapter, the const
specifier on the function declaration ensures that the object is not modified by this function.
That property function enables the struct to be used as if it has a third member variable:
auto garden = Rectangle(10, 20); writeln("The area of the garden: ", garden.area);
As the value of the area
property is calculated by multiplying the width and the height of the rectangle, this time it would always be consistent:
The area of the garden: 200
Property functions that are used in assignment
Similar to the length
property of slices, the properties of user-defined types can be used in assignment operations as well:
garden.area = 50;
For that assignment to actually change the area of the rectangle, the two members of the struct must be modified accordingly. To enable this functionality, we can assume that the rectangle is flexible so that to maintain the invariant of "width * height == area", the sides of the rectangle can be changed.
The function that enables such an assignment syntax is also named as area
. The value that is used on the right-hand side of the assignment becomes the only parameter of this function.
The following additional definition of area()
enables using that property in assignment operations and effectively modifying the area of Rectangle
objects:
import std.stdio; import std.math; struct Rectangle { double width; double height; double area() const { return width * height; } void area(double newArea) { auto scale = sqrt(newArea / area); width *= scale; height *= scale; } } void main() { auto garden = Rectangle(10, 20); writeln("The area of the garden: ", garden.area); garden.area = 50; writefln("New state: %s x %s = %s", garden.width, garden.height, garden.area); }
The new function takes advantage of the sqrt
function from the std.math
module, which returns the square root of the specified value. When both of the width and the height of the rectangle are scaled by the square root of the ratio, then the area would equal the desired value.
As a result, assigning the quarter of its current value to area
ends up halving both sides of the rectangle:
The area of the garden: 200 New state: 5 x 10 = 50
Properties are not absolutely necessary
We have seen above how Rectangle
can be used as if it has a third member variable. However, regular member functions could also be used instead of properties:
import std.stdio; import std.math; struct Rectangle { double width; double height; double area() const { return width * height; } void setArea(double newArea) { auto scale = sqrt(newArea / area); width *= scale; height *= scale; } } void main() { auto garden = Rectangle(10, 20); writeln("The area of the garden: ", garden.area()); garden.setArea(50); writefln("New state: %s x %s = %s", garden.width, garden.height, garden.area()); }
Further, as we have seen in the Function Overloading chapter, these two functions could even have the same names:
double area() const { // ... } void area(double newArea) { // ... }
When to use
It may not be easy to chose between regular member functions and properties. Sometimes regular member functions feel more natural and sometimes properties.
However, as we have seen in the Encapsulation and Protection Attributes chapter, it is important to restrict direct access to member variables. Allowing user code to freely modify member variables always ends up causing issues with code maintenance. For that reason, member variables better be encapsulated either by regular member functions or by property functions.
Leaving members like width
and height
open to public
access is acceptable only for very simple types. Almost always a better design is to use property functions:
struct Rectangle { private: double width_; double height_; public: double area() const { return width * height; } void area(double newArea) { auto scale = sqrt(newArea / area); width_ *= scale; height_ *= scale; } double width() const { return width_; } double height() const { return height_; } }
Note how the members are made private
so that they can only be accessed by corresponding property functions.
Also note that to avoid confusing their names with the member functions, the names of the member variables are appended by the _
character. Decorating the names of member variables is a common practice in object oriented programming.
That definition of Rectangle
still presents width
and height
as if they are member variable:
auto garden = Rectangle(10, 20); writefln("width: %s, height: %s", garden.width, garden.height);
When there is no property function that modifies a member variable, then that member is effectively read-only from the outside:
garden.width = 100; // ← compilation ERROR
This is important for controlled modifications of members. The member variables can only be modified by the Rectangle
type itself to ensure the consistency of its objects.
When it later makes sense that a member variable should be allowed to be modified from the outside, then it is simply a matter of defining another property function for that member.
@property
Property functions may be defined with the @property
attribute as well. However, as a best practice, the use of this attribute is discouraged.
import std.stdio; struct Foo { @property int a() const { return 42; } int b() const { // ← Defined without @property return 42; } } void main() { auto f = Foo(); writeln(typeof(f.a).stringof); writeln(typeof(f.b).stringof); }
The only effect of the @property
attribute is when determining the type of an expression that could syntactically be a property function call. As seen in the output below, the types of the expressions f.a
and f.b
are different:
int ← The type of the expression f.a (the return type) const int() ← The type of the member function Foo.b