Types, Objects, and Systems, Oh my!

Inextricably bound

Perl isn't a strongly typed language, and its built-in types are limited and not generally accessible to the engineer, however, Perl supports various classes of data and in recent years has flirted with various ways of enabling runtime type checking.

In a strongly typed language the tl;dr; case for declaring data types is memory management, compile-time code optimization, and correctness. To this day I'm both impressed and horrified by the number of errors caught when I implement some kind of type checking in my programs. When it comes to runtime type checking we're only concerned with enforcing correctness.

Types, values, objects, signatures, and the systems that tie these all together, are all inextricably bound. They are necessarily interdependent in order to present/provide a cohesive and consistent system. Peeling back the layers a bit, types are merely classifications of data. Any given piece of data can be classified as belonging to a particular type whether implicit or explicit.

Types are instantiated (i.e. have concrete representations, i.e. instances) whenever data is created and/or declared as conforming to the type's criteria. Runtime types are arbitrary. A value of 1 can be said to be of type number where the value "1" can be said to be of the type string. Also in Perl, an object is a specific kind of reference; a reference tied to a particular namespace.

Runtime type libraries

Currently, as per the CPAN, there are a few different ways to declare and use runtime type checking in your application. The three most popular libraries, in no particular order, are, MooseX::Types, Type::Tiny, and Specio. All of these type libraries have Moo/se compatibility as their stated goal.

MooseX::Types (2009) was designed to address the Moose global registry (and type conflict) problem.

package MyLibrary;

use MooseX::Types -declare => [
  'PositiveInt',
  'NegativeInt',
];

use MooseX::Types::Moose 'Int';

subtype PositiveInt,
  as Int,
  where { $_ > 0 },
  message { "Int is not larger than 0" };

subtype NegativeInt,
  as Int,
  where { $_ < 0 },
  message { "Int is not smaller than 0" };

1;

Type::Tiny (2013), inspired by MooseX::Types, was designed to have a small footprint, a single non-core dependency, a set of "core" types as a standard library, and to have first-class support for use with Moo.

package MyLibrary;

use Type::Library -base;
use Type::Utils -all;

BEGIN { extends "Types::Standard" };

declare 'PositiveInt',
  as 'Int',
  where { $_ > 0 },
  message { "Int is not larger than 0" };

declare 'NegativeInt',
  as 'Int',
  where { $_ < 0 },
  message { "Int is not smaller than 0" };

1;

Specio (2013) is meant to be a replacement for Moose's built-in types, MooseX::Types, and the Moose global registry (and type conflict) problem.

package MyLibrary;

use Specio::Declare;
use Specio::Library::Builtins;

declare(
  'PositiveInt',
  parent => t('Int'),
  where  => sub { $_[0] > 0 },
  message_generator => sub { "Int is not larger than 0" },
);

declare(
  'NegativeInt',
  parent => t('Int'),
  where  => sub { $_[0] < 0 },
  message_generator => sub { "Int is not smaller than 0" },
);

1;

What these libraries have in common is the concept of declaring custom types using a DSL and organizing and exporting types from one or more registries. They also (in practice) produce registries that act as exporters that litter the calling package with use-once functions which require namespace::autoclean to get rid of. To be fair, both Type-Tiny and Specio have object-oriented interfaces that allow you to build types and registries without using the DSL.

Introducing Venus::Assert

Meet Venus::Assert, a simple data type assertion class that could serve as the foundation for a future object-oriented type assertion and coercion system for Perl 5.

Venus, the non-core object-oriented standard library, by necessity needs to be able to distinguish between different types of data. It's how the library is capable of distinguishing between the number 1, the string 1, and the conventional boolean 1.

Venus::Assert wraps that know-how in an intuitive utility class that behaves in the tradition of its siblings and provides the foundations for a future Venus-based unified type checking system.

Because Venus::Assert is a Venus utility class it can, without any additional code, complexity, or effort, throw itself, catch itself, try itself, etc.

Throws itself

Venus::Assert->new('PositiveNum')->number(sub{$_->value > 0})->throw;

Catches itself

my ($result, $error) = Venus::Assert->new('NegativeNum')->number(sub{$_->value < 0})->catch('validate', $value);

Tries itself

my $tryable = Venus::Assert->new('PositiveNum')->number(sub{$_->value > 0})->try('validate', $value);

Venus::Assert doesn't have a DSL, doesn't support or encourage type registries, doesn't concern itself with inlining, and doesn't declare parent types to be extended. Venus::Assert instances are simple code-convertible objects built on Venus::Match for powerful object-oriented case/switch operations. Code-convertible custom types can be declared as plain ole packages which conform to a particular interface:

package MyApp::Type::PositiveNumber;

use base 'Venus::Assert';

sub conditions {
  my ($self) = @_;

  $self->number(sub{$_->value > 0});
}

1;

Extending custom types with proper names and explanations (on failure) by doing something like the following:

package MyApp::Type::PositiveNumber;

use base 'Venus::Assert';

sub name {
  my ($self) = @_;
  return $self->class;
}

sub message {
  my ($self) = @_;
  return 'Type assertion (%s) failed, expects a number > 0, received (%s)';
}

sub conditions {
  my ($self) = @_;
  return $self->number(sub{$_->value > 0});
}

Types::Standard via Venus::Assert

We could easily use Venus::Assert to approximate 90% of what the Type::Tiny Types::Standard library does, with a few lines of code. For example:

Any

Venus::Assert->new->any

... or

Venus::Assert->new(on_none => sub{true})

Bool

Venus::Assert->new->boolean

Maybe[a]

Venus::Assert->new->maybe($a)

... or

Venus::Assert->new->maybe->$a

... or

Venus::Assert->new->undef->$a

Undef

Venus::Assert->new->undef

Defined

Venus::Assert->new->defined

... or

Venus::Assert->new->when(sub{defined $_->value})->then(sub{true})

Value

Venus::Assert->new->value

... or

Venus::Assert->new->when(sub{defined $_->value && !ref $_->value})->then(sub{true})

Str

Venus::Assert->new->string

Num

Venus::Assert->new->number

ClassName

Venus::Assert->new->package

... or

Venus::Assert->new->string->constraints->where->defined(sub{
  Venus::Space->new($_->value)->loaded
})

Ref[a]

Venus::Assert->new->reference

... or

Venus::Assert->new->defined(sub{
  ref($_->value) eq $a
})

ScalarRef[a]

Venus::Assert->new->scalar

... or

Venus::Assert->new->scalar(sub{
  Venus::Assert->new->$a->check($_->value)
});

ArrayRef[a]

Venus::Assert->new->array

... or

Venus::Assert->new->array(sub{
  Venus::Assert->new->$a->check($_->value)
});

HashRef[a]

Venus::Assert->new->hash

... or

Venus::Assert->new->array(sub{
  Venus::Assert->new->$a->check($_->value)
});

CodeRef

Venus::Assert->new->code

RegexpRef

Venus::Assert->new->regexp

GlobRef

Venus::Assert->new->reference(sub{ref $_->value eq 'GLOB'})

Object

Venus::Assert->new->object

Tuple[a]

Venus::Assert->new->tuple(@a)

InstanceOf[a]

Venus::Assert->new->identity($a)

ConsumerOf[a]

Venus::Assert->new->consumes($a)

HasMethods[a]

Venus::Assert->new->routines(@a)

StrMatch[a]

Venus::Assert->new->string(sub{
  $_->deduce->contains($a)
})

Enum[a]

Venus::Assert->new->enum(@a)

The state of the art

Again, types, objects, signatures, and systems, are inextricably bound which means that a good system will provide both architecture and abstractions to support interoperability, or at least declare its way as the one true way. What we have today is an assortment of libraries that tackle a particular aspect of the "runtime type checking" system. Your mileage may vary with regard to interoperability.

Moo/se allows you to type constrain class attributes using MooseX::Types, Type::Tiny, and/or Specio, etc. Moose (last time I check) uses a single global type registry and isn't designed to play nice with others. Moo, to its credit, provides a simple system-agnostic interface, i.e. accepts code-convertible values, which Type::Tiny takes full advantage of. Type::Tiny goes through some pains (IMHO) to make itself Moose-aware and interoperable. None of these libraries prescribed a methodology for reusing the declared types in function/method signatures. Method::Signatures provides function and method signatures but only supports Moose types (as per its global registry). Function::Parameters goes a bit further and does provide an abstraction for hooking into the type resolution mechanism as well as the ability to use local functions in signature type notations.

The Perl "signatures" feature provided bare-bones untyped/untypable signatures, and is little more than syntactic sugar for argument unpacking. The registry/routines pragmas attempt to bring together Function::Parameters and Type::Tiny to provide a unified system for runtime type checking. All of the type libraries support parameterized type declarations, and yet none of the signature pragmas/parsers do.

The future, hopefully

To have proper compile-time types (which are extendable), they need to be built into the language, in which case you'll likely end up with something like Raku.

To have proper runtime types which feel natural and legit in Perl 5 we need to nail the interoperability problem, and to do that we need to devise a standard that allows systems to cooperate.

We need package fields, objects, properties, values, and subroutine signatures to be capable of using the same vocabulary and type notation to declare type constraints, thus recognizing and accepting the same types and data.

Here's an idea

Simple architecture:

  • A type is simply a package with "check" and "make" routines
  • The "check" routine accepts any value and returns a tuple, i.e. (valid?, explanation)
  • The "make" routine accepts any value and returns the value (possibly modified) and the result of the "check" routine, i.e. a tuple of (value, valid?, explanation)
  • A "types" statement, appends the currently scoped @TYPES global, allowing the use of shortnames when referring to types
  • The "type" function accepts a string-based type expression, and any value, resolving the type expression and executing the type's "make" function

Declare custom type

package MyApp::Type::String;

sub check {
  my ($value) = @_;
  (false, 'Not a string')
}

sub make {
  my ($value) = @_
  ($value, check($value));
}

1;

Resolve custom type

package MyApp;

use the-idea-materialized;

types 'MyApp::Type';

my ($value) = type('String', 'hello');

1;

Governance rules and notation:

  • No parameterized types
  • Type resolution fallback to some "core" namespace if local @TYPES global is empty

Sources

Object-systems

Moo

Moose

Type libraries

MooseX::Types

Type::Tiny

Specio

Type::Library

Types::Standard

Subroutine signatures

Function::Parameters

Method::Signatures

registry/routines

End Quote

"Check yo' self before you wreck yo' self" - Ice Cube

2 Comments

Two thoughts:

1) Signatures are now enabled by default in 5.36 onward (https://perldoc.perl.org/perldelta#Core-Enhancements). An OO system (Corinna (https://ovid.github.io/articles/current-corinna-status.html) appears to be making its way into core. How do you see these as affecting Venus?

2) What is the speed penalty, if any, for using Venus? We all know Moose is a startup pig; hence the existence of Moo.

What an excellent thought provoking article, on yet another OOP paradigm. Nice work, Al.

Leave a comment

About awncorp

user-pic