Types, Objects, and Systems, Oh my!

iamalnewkirk

Al Newkirk

Posted on September 19, 2022

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Catches itself

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

Tries itself

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

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;
Enter fullscreen mode Exit fullscreen mode

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});
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

... or

Venus::Assert->new(on_none => sub{true})
Enter fullscreen mode Exit fullscreen mode

Bool

Venus::Assert->new->boolean
Enter fullscreen mode Exit fullscreen mode

Maybe[a]

Venus::Assert->new->maybe($a)
Enter fullscreen mode Exit fullscreen mode

... or

Venus::Assert->new->maybe->$a
Enter fullscreen mode Exit fullscreen mode

... or

Venus::Assert->new->undef->$a
Enter fullscreen mode Exit fullscreen mode

Undef

Venus::Assert->new->undef
Enter fullscreen mode Exit fullscreen mode

Defined

Venus::Assert->new->defined
Enter fullscreen mode Exit fullscreen mode

... or

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

Value

Venus::Assert->new->value
Enter fullscreen mode Exit fullscreen mode

... or

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

Str

Venus::Assert->new->string
Enter fullscreen mode Exit fullscreen mode

Num

Venus::Assert->new->number
Enter fullscreen mode Exit fullscreen mode

ClassName

Venus::Assert->new->package
Enter fullscreen mode Exit fullscreen mode

... or

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

Ref[a]

Venus::Assert->new->reference
Enter fullscreen mode Exit fullscreen mode

... or

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

ScalarRef[a]

Venus::Assert->new->scalar
Enter fullscreen mode Exit fullscreen mode

... or

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

ArrayRef[a]

Venus::Assert->new->array
Enter fullscreen mode Exit fullscreen mode

... or

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

HashRef[a]

Venus::Assert->new->hash
Enter fullscreen mode Exit fullscreen mode

... or

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

CodeRef

Venus::Assert->new->code
Enter fullscreen mode Exit fullscreen mode

RegexpRef

Venus::Assert->new->regexp
Enter fullscreen mode Exit fullscreen mode

GlobRef

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

Object

Venus::Assert->new->object
Enter fullscreen mode Exit fullscreen mode

Tuple[a]

Venus::Assert->new->tuple(@a)
Enter fullscreen mode Exit fullscreen mode

InstanceOf[a]

Venus::Assert->new->identity($a)
Enter fullscreen mode Exit fullscreen mode

ConsumerOf[a]

Venus::Assert->new->consumes($a)
Enter fullscreen mode Exit fullscreen mode

HasMethods[a]

Venus::Assert->new->routines(@a)
Enter fullscreen mode Exit fullscreen mode

StrMatch[a]

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

Enum[a]

Venus::Assert->new->enum(@a)
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Resolve custom type

package MyApp;

use the-idea-materialized;

types 'MyApp::Type';

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

1;
Enter fullscreen mode Exit fullscreen mode

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

Authors

Awncorp, awncorp@cpan.org

💖 💪 🙅 🚩
iamalnewkirk
Al Newkirk

Posted on September 19, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related