1
0
Fork 0

Vanilla ECMAScript interop patches

Now that ease.js is a GNU project, it has much broader reach than before.
Since its very existence is controversial, it would be wise (and polite) to
provide a means for others to integrate with libraries written using ease.js
without being forced to use ease.js themselves. Further, ease.js users
should be able to build off of the work of other libraries that do not use
ease.js.

This set of changes introduces a number of interoperability improvements,
documented in the new manual chapter ``Interoperability''. Since it is
documented in the manual, this commit message will not go into great detail;
I wish to only provide a summary.

Firstly, we now have the concept of interface compatibility; while ease.js
classes/etc must still conform to the existing interface requirements, the
rules are a bit more lax for other ECMAScript objects to permit
interoperability with type-checking ease.js code. For example:

  var I   = Interface( { foo: [ 'a' ] } ),
      obj = { foo: function( a ) {} };

  Class.isA( I, obj );  // true

This is also a powerful feature for implementing interfaces around existing
objects, as a preemptive interface check (rather than duck typing).

Prototypally extending ease.js classes is potentially problematic because
the constructor may perform argument validations (this is also an issue in
pure prototypal code). As a solution, all classes now have a static
`asPrototype` method, which defers constructor invocation, trusting that the
prototype constructor will do so itself.

Aside from a few bug fixes, there is also a more concise notation for
private members to allow prototypal developers to feel more at home when
using GNU ease.js: members prefixed with an underscore are now implicitly
private, which will satisfy the most common visibility use cases. I do
recognize that some (mostly in the Java community) use underscore *suffixes*
to denote private members, but I've noticed that this is relatively rare in
the JS community; I have therefore not included such a check, but will
consider it if many users request it.

There are many more ideas to come, but I hope that this will help to bridge
the gap between the prototypal and classical camps, allowing them to
cooperate with as little friction as possible.
newmaster
Mike Gerwitz 2014-04-27 23:32:57 -04:00
commit 8b99cb7f70
No known key found for this signature in database
GPG Key ID: F22BB8158EE30EAB
10 changed files with 1278 additions and 28 deletions

View File

@ -21,7 +21,7 @@
##
info_TEXINFOS = easejs.texi
easejs_TEXINFOS = license.texi about.texi classes.texi \
easejs_TEXINFOS = license.texi about.texi classes.texi interop.texi \
impl-details.texi integration.texi mkeywords.texi source-tree.texi
EXTRA_DIST = img README highlight.pack.js interactive.js easejs.css

View File

@ -55,6 +55,7 @@ Free Documentation License".
* Integration:: How to integrate ease.js into your project
* Classes:: Learn to work with Classes
* Member Keywords:: Control member visibility and more.
* Interoperability:: Playing nice with vanilla ECMAScript.
* Source Tree:: Overview of source tree
* Implementation Details:: The how and why of ease.js
* License:: Document License
@ -69,6 +70,7 @@ Free Documentation License".
@include integration.texi
@include classes.texi
@include mkeywords.texi
@include interop.texi
@include source-tree.texi
@include impl-details.texi
@include license.texi

367
doc/interop.texi 100644
View File

@ -0,0 +1,367 @@
@c This document is part of the GNU ease.js manual.
@c Copyright (C) 2014 Free Software Foundation, Inc.
@c Permission is granted to copy, distribute and/or modify this document
@c under the terms of the GNU Free Documentation License, Version 1.3 or
@c any later version published by the Free Software Foundation; with no
@c Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts.
@c A copy of the license is included in the section entitled ``GNU Free
@c Documentation License''.
@node Interoperability
@chapter Interoperability
GNU ease.js is not for everyone, so it is important to play nicely with
vanilla ECMAScript so that prototypes and objects can be integrated with
the strict restrictions of ease.js (imposed by classical OOP). In general,
you should not have to worry about this: everything is designed to work
fairly transparently. This chapter will go over what ease.js intentionally
supports and some interesting concepts that may even be useful even if you
have adopted ease.js for your own projects.
@menu
* Using GNU ease.js Classes Outside of ease.js::
* Prototypally Extending Classes::
* Interoperable Polymorphism::
@end menu
@node Using GNU ease.js Classes Outside of ease.js
@section Using GNU ease.js Classes Outside of ease.js
GNU ease.js is a prototype generator---it takes the class definition,
applies its validations and conveniences, and generates a prototype and
constructor that can be instantiated and used just as any other ECMAScript
constructor/prototype. One thing to note immediately, as mentioned in
the section @ref{Defining Classes,,Defining Classes}, is that constructors
generated by ease.js may be instantiated either with or without the
@code{new} keyword:
@float Figure, f:interop-new
@verbatim
var Foo = Class( { /*...*/ } );
// both of these are equivalent
Foo();
new Foo();
@end verbatim
@caption{Constructors generated by ease.js may omit the @code{new} keyword}
@end float
ease.js convention is to omit the keyword for more concise code that is more
easily chained, but you should follow the coding conventions of the project
that you are working on.
@node Prototypally Extending Classes
@section Prototypally Extending Classes
Since @ref{Classes,,classes} are also constructors with prototypes, they may
be used as part of a prototype chain. There are, however, some important
considerations when using any sort of constructor as part of a prototype
chain.
Conventionally, prototypes are subtyped by using a new instance as the
prototype of the subtype's constructor, as so:
@float Figure, f:interop-protochain-incorrect
@verbatim
var Foo = Class( { /*...*/ } );
// extending class as a prototype
function SubFoo() {};
SubFoo.prototype = Foo(); // INCORRECT
SubFoo.prototype.constructor = SubFoo;
@end verbatim
@caption{Incorrectly prototypally extending GNU ease.js classes}
@end float
The problem with this approach is that constructors may perform validations
on their arguments to ensure that the instance is in a consistent state. GNU
ease.js solves this problem by introducing an @code{asPrototype} method on
all classes:
@float Figure, f:interop-protochain
@verbatim
var Foo = Class( { /*...*/ } );
// extending class as a prototype
function SubFoo()
{
// it is important to call the constructor ourselves; this is a
// generic method that should work for all subtypes, even if SubFoo
// implements its own __construct method
this.constructor.prototype.__construct.apply( this, arguments );
// OR, if SubFoo does not define its own __construct method, you can
// alternatively do this:
this.__construct();
};
SubFoo.prototype = Foo.asPrototype(); // Correct
SubFoo.prototype.constructor = SubFoo;
@end verbatim
@caption{Correctly prototypally extending GNU ease.js classes}
@end float
The @code{asPrototype} method instantiates the class, but does not execute
the constructor. This allows it to be used as the prototype without any
issues, but it is important that the constructor of the subtype invokes the
constructor of the class, as in @ref{f:interop-protochain}. Otherwise, the
state of the subtype is undefined.
Keep in mind the following when using classes as part of the prototype
chain:
@itemize
@item
GNU ease.js member validations are not enforced; you will not be warned if
an abstract method remains unimplemented or if you override a non-virtual
method, for example. Please exercise diligence.
@item
It is not wise to override non-@ref{Inheritance,,virtual} methods, because
the class designer may not have exposed a proper API for accessing and
manipulating internal state, and may not provide proper protections to
ensure consistent state after the method call.
@item
Note the @ref{Private Member Dilemma} to ensure that your prototype works
properly in pre-ES5 environments and with potential future ease.js
optimizations for production environments: you should not define or
manipulate properties on the prototype that would conflict with private
members of the subtype. This is an awkward situation, since private
members are unlikely to be included in API documentation for a class;
ease.js normally prevents this from happening automatically.
@end itemize
@node Interoperable Polymorphism
@section Interoperable Polymorphism
GNU ease.js encourages polymorphism through type checking. In the case of
@ref{Prototypally Extending Classes,,prototypal subtyping}, type checks will
work as expected:
@float Figure, f:typecheck-protosub
@verbatim
var Foo = Class( {} );
function SubFoo() {};
SubFoo.prototype = Foo.asPrototype();
SubFoo.constructor = Foo;
var SubSubFoo = Class.extend( SubFoo, {} );
// vanilla ECMAScript
( new Foo() ) instanceof Foo; // true
( new Subfoo() ) instanceof Foo; // true
( new SubSubFoo() ) instanceof Foo; // true
( new SubSubFoo() ) instanceof SubFoo; // true
// GNU ease.js
Class.isA( Foo, ( new Foo() ) ); // true
Class.isA( Foo, ( new SubFoo() ) ); // true
Class.isA( Foo, ( new SubSubFoo() ) ); // true
Class.isA( SubFoo, ( new SubSubFoo() ) ); // true
@end verbatim
@caption{Type checking with prototypal subtypes of GNU ease.js classes}
@end float
Plainly---this means that prototypes that perform type checking for
polymorphism will accept GNU ease.js classes and vice versa. But this is not
the only form of type checking that ease.js supports.
This is the simplest type of polymorphism and is directly compatible with
ECMAScript's prototypal mode. However, GNU ease.js offers other features
that are alien to ECMAScript on its own.
@menu
* Interface Interop:: Using GNU ease.js interfaces in conjunction with
vanilla ECMAScript
@end menu
@node Interface Interop
@subsection Interface Interop
@ref{Interfaces}, when used within the bounds of GNU ease.js, allow for
strong typing of objects. Further, two interfaces that share the same API
are not equivalent; this permits conveying intent: Consider two interfaces
@code{Enemy} and @code{Toad}, each defining a method @code{croak}. The
method for @code{Enemy} results in its death, whereas the method for
@code{Toad} produces a bellowing call. Clearly classes implementing these
interfaces will have different actions associated with them; we would
probably not want an invincible enemy that croaks like a toad any time you
try to kill it (although that'd make for amusing gameplay).
@float figure, f:interface-croak
@verbatim
var Enemy = Interface( { croak: [] } ),
Toad = Interface( { croak: [] } ),
AnEnemy = Class.implement( Enemy ).extend( /*...*/ ),
AToad = Class.implement( Toad ).extend( /*...*/ );
// GNU ease.js does not consider these interfaces to be equivalent
Class.isA( Enemy, AnEnemy() ); // true
Class.isA( Toad, AnEnemy() ); // false
Class.isA( Enemy, AToad() ); // false
Class.isA( Toad, AToad() ); // true
defeatEnemy( AnEnemy() ); // okay; is an enemy
defeatEnemy( AToad() ); // error; is a toad
function defeatEnemy( enemy )
{
if ( !( Class.isA( Enemy, enemy ) ) ) {
throw TypeError( "Expecting enemy" );
}
enemy.croak();
}
@end verbatim
@caption{Croak like an enemy or a toad?}
@end float
In JavaScript, it is common convention to instead use @emph{duck typing},
which does not care what the intent of the interface is---it merely cares
whether the method being invoked actually exists.@footnote{``When I see a
bird that walks like a duck and swims like a duck and quacks like a duck, I
call that bird a duck.'' (James Whitcomb Riley).} So, in the case of the
above example, it is not a problem that an toad may be used in place of an
enemy---they both implement @code{croak} and so @emph{something} will
happen. This is most often exemplified by the use of object literals to
create ad-hoc instances of sorts:
@float figure, f:interface-objlit
@verbatim
var enemy = { croak: function() { /* ... */ ) },
toad = { croak: function() { /* ... */ ) };
defeatEnemy( enemy ); // okay; duck typing
defeatEnemy( toad ); // okay; duck typing
// TypeError: object has no method 'croak'
defeatEnemy( { moo: function() { /*...*/ } } );
function defeatEnemy( enemy )
{
enemy.croak();
}
@end verbatim
@caption{Duck typing with object literals}
@end float
Duck typing has the benefit of being ad-hoc and concise, but places the onus
on the developer to realize the interface and ensure that it is properly
implemented. Therefore, there are two situations to address for GNU ease.js
users that prefer strongly typed interfaces:
@enumerate
@item
Ensure that non-ease.js users can create objects acceptable to the
strongly-typed API; and
@item
Allow ease.js classes to require a strong API for existing objects.
@end enumerate
These two are closely related and rely on the same underlying concepts.
@menu
* Object Interface Compatibility:: Using vanilla ECMAScript objects where
type checking is performed on GNU ease.js
interfaces
* Building Interfaces Around Objects:: Using interfaces to validate APIs of
ECMAScript objects
@end menu
@node Object Interface Compatibility
@subsubsection Object Interface Compatibility
It is clear that GNU ease.js' distinction between two separate interfaces
that share the same API is not useful for vanilla ECMAScript objects,
because those objects do not have an API for implementing interfaces (and if
they did, they wouldn't be ease.js' interfaces). Therefore, in order to
design a transparently interoperable system, this distinction must be
removed (but will be @emph{retained} within ease.js' system).
The core purpose of an interface is to declare an expected API, providing
preemptive warnings and reducing the risk of runtime error. This is in
contrast with duck typing, which favors recovering from errors when (and if)
they occur. Since an ECMAScript object cannot implement an ease.js interface
(if it did, it'd be using ease.js), the conclusion is that ease.js should
fall back to scanning the object to ensure that it is compatible with a
given interface.
A vanilla ECMAScript object is compatible with an ease.js interface if it
defines all interface members and meets the parameter count requirements of
those members.
@float Figure, f:interface-compat
@verbatim
var Duck = Interface( {
quack: [ 'str' ],
waddle: [],
} );
// false; no quack
Class.isA( Duck, { waddle: function() {} } );
// false; quack requires one parameter
Class.isA( Duck, {
quack: function() {},
waddle: function() {},
} );
// true
Class.isA( Duck, {
quack: function( str ) {},
waddle: function() {},
} );
// true
function ADuck() {};
ADuck.prototype = {
quack: function( str ) {},
waddle: function() {},
};
Class.isA( Duck, ( new ADuck() ) );
@end verbatim
@caption{Vanilla ECMAScript object interface compatibility}
@end float
@node Building Interfaces Around Objects
@subsubsection Building Interfaces Around Objects
A consequence of @ref{Object Interface Compatibility,,the previous section}
is that users of GNU ease.js can continue to use strongly typed interfaces
even if the objects they are interfacing with do not support ease.js'
interfaces. Consider, for example, a system that uses @code{XMLHttpRequest}:
@float Figure, f:interface-xmlhttp
@verbatim
// modeled around XMLHttpRequest
var HttpRequest = Interface(
{
abort: [],
open: [ 'method', 'url', 'async', 'user', 'password' ],
send: [],
} );
var FooApi = Class(
{
__construct: function( httpreq )
{
if ( !( Class.isA( HttpRequest, httpreq ) ) )
{
throw TypeError( "Expecting HttpRequest" );
}
// ...
}
} );
FooApi( new XMLHttpRequest() ); // okay
@end verbatim
@caption{Building an interface around needed functionality of
XMLHttpRequest}
@end float
This feature permits runtime polymorphism with preemptive failure instead of
inconsistently requiring duck typing for external objects, but interfaces for
objects handled through ease.js.

View File

@ -244,10 +244,33 @@ exports.isInstanceOf = function( type, instance )
return false;
}
// defer check to type, falling back to a more primitive check; this
// also allows extending ease.js' type system
return !!( type.__isInstanceOf || _instChk )( type, instance );
}
/**
* Wrapper around ECMAScript instanceof check
*
* This will not throw an error if TYPE is not a function.
*
* Note that a try/catch is used instead of checking first to see if TYPE is
* a function; this is due to the implementation of, notably, IE, which
* allows instanceof to be used on some DOM objects with typeof `object'.
* These same objects have typeof `function' in other browsers.
*
* @param {*} type constructor to check against
* @param {Object} instance instance to examine
*
* @return {boolean} whether INSTANCE is an instance of TYPE
*/
function _instChk( type, instance )
{
try
{
// check prototype chain (will throw an error if type is not a
// constructor (function)
// constructor)
if ( instance instanceof type )
{
return true;
@ -255,28 +278,8 @@ exports.isInstanceOf = function( type, instance )
}
catch ( e ) {}
// if no metadata is available, then our remaining checks cannot be
// performed
if ( !instance.__cid || !( meta = exports.getMeta( instance ) ) )
{
return false;
}
implemented = meta.implemented;
i = implemented.length;
// check implemented interfaces et. al. (other systems may make use of
// this meta-attribute to provide references to types)
while ( i-- )
{
if ( implemented[ i ] === type )
{
return true;
}
}
return false;
};
}
/**
@ -296,6 +299,8 @@ exports.isInstanceOf = function( type, instance )
*/
exports.prototype.build = function extend( _, __ )
{
var build = this;
// ensure we'll be permitted to instantiate abstract classes for the base
this._extending = true;
@ -357,6 +362,14 @@ exports.prototype.build = function extend( _, __ )
// increment class identifier
this._classId++;
// if we are inheriting from a prototype, we must make sure that all
// properties initialized by the ctor are implicitly public; otherwise,
// proxying will fail to take place
if ( !( prototype instanceof exports.ClassBase ) )
{
this._discoverProtoProps( prototype, prop_init );
}
// build the various class components (XXX: this is temporary; needs
// refactoring)
try
@ -434,6 +447,16 @@ exports.prototype.build = function extend( _, __ )
attachAbstract( new_class, abstract_methods );
attachId( new_class, this._classId );
// returns a new instance of the class without invoking the constructor
// (intended for use in prototype chains)
new_class.asPrototype = function()
{
build._extending = true;
var inst = new_class();
build._extending = false;
return inst;
};
// we're done with the extension process
this._extending = false;
@ -462,6 +485,55 @@ exports.prototype._getBase = function( base )
};
/**
* Discovers public properties on the given object and create an associated
* property
*
* This allows inheriting from a prototype that uses properties by ensuring
* that we properly proxy to that property. Otherwise, assigning the value
* on the private visibilit object would mask the underlying value rather
* than modifying it, leading to an inconsistent and incorrect state.
*
* This assumes that the object has already been initialized with all the
* properties. This may not be the case if the prototype constructor does
* not do so, in which case there is nothing we can do.
*
* This does not recurse on the prototype chian.
*
* For a more detailed description of this issue, see the interoperability
* test case for classes.
*
* @param {Object} obj object from which to gather properties
* @param {Object} prop_init destination property object
*
* @return {undefined}
*/
exports.prototype._discoverProtoProps = function( obj, prop_init )
{
var hasOwn = Object.hasOwnProperty,
pub = prop_init[ 'public' ];
for ( var field in obj )
{
var value = obj[ field ];
// we are not interested in the objtype chain, nor are we
// interested in functions (which are methods and need not be
// proxied)
if ( !( hasOwn.call( obj, field ) )
|| typeof value === 'function'
)
{
continue;
}
this._memberBuilder.buildProp(
prop_init, null, field, value, {}
);
}
};
exports.prototype.buildMembers = function buildMembers(
props, class_id, base, prop_init, memberdest, staticInstLookup
)

View File

@ -120,11 +120,16 @@ exports.buildMethod = function(
members, meta, name, value, keywords, instCallback, cid, base, state
)
{
// these defaults will be used whenever a keyword set is unavailable,
// which should only ever be the case if we're inheriting from a
// prototype rather than an ease.js class/etc
var kdefaults = this._methodKeywordDefaults;
// TODO: We can improve performance by not scanning each one individually
// every time this method is called
var prev_data = scanMembers( members, name, base ),
prev = ( prev_data ) ? prev_data.member : null,
prev_keywords = ( prev && prev.___$$keywords$$ ),
prev_keywords = ( prev && ( prev.___$$keywords$$ || kdefaults ) ),
dest = getMemberVisibility( members, keywords, name );
;
@ -194,6 +199,13 @@ exports.buildMethod = function(
};
/**
* Default keywords to apply to methods inherited from a prototype.
* @type {Object}
*/
exports._methodKeywordDefaults = { 'virtual': true };
/**
* Creates an abstract override super method proxy to NAME
*

View File

@ -20,8 +20,8 @@
*/
var AbstractClass = require( './class_abstract' ),
ClassBuilder = require( './ClassBuilder' );
ClassBuilder = require( './ClassBuilder' ),
Interface = require( './interface' );
/**
* Trait constructor / base object
@ -151,6 +151,9 @@ Trait.extend = function( dfn )
mixinImpl( tclass, dest_meta );
};
// TODO: this and the above should use util.defineSecureProp
TraitType.__isInstanceOf = Interface.isInstanceOf;
return TraitType;
};

View File

@ -31,8 +31,8 @@ var util = require( './util' ),
require( './MemberBuilderValidator' )()
),
Class = require( './class' )
;
Class = require( './class' ),
ClassBuilder = require( './ClassBuilder' );;
/**
@ -268,6 +268,8 @@ var extend = ( function( extending )
attachExtend( new_interface );
attachStringMethod( new_interface, iname );
attachCompat( new_interface );
attachInstanceOf( new_interface );
new_interface.prototype = prototype;
new_interface.constructor = new_interface;
@ -376,3 +378,142 @@ function attachStringMethod( func, iname )
;
}
/**
* Attaches a method to assert whether a given object is compatible with the
* interface
*
* @param {Function} iface interface to attach method to
*
* @return {undefined}
*/
function attachCompat( iface )
{
util.defineSecureProp( iface, 'isCompatible', function( obj )
{
return isCompat( iface, obj );
} );
}
/**
* Determines if the given object is compatible with the given interface.
*
* An object is compatible if it defines all methods required by the
* interface, with at least the required number of parameters.
*
* Processing time is linear with respect to the number of members of the
* provided interface.
*
* To get the actual reasons in the event of a compatibility failure, use
* analyzeCompat instead.
*
* @param {Interface} iface interface that must be adhered to
* @param {Object} obj object to check compatibility against
*
* @return {boolean} true if compatible, otherwise false
*/
function isCompat( iface, obj )
{
// yes, this processes the entire interface, but it is hopefully small
// anyway and the process is fast enough that doing otherwise may be
// micro-optimizing
return analyzeCompat( iface, obj ).length === 0;
}
/**
* Analyzes the given object to determine if there exists any compatibility
* issues with respect to the given interface
*
* Will provide an array of the names of incompatible members. A method is
* incompatible if it is not defined or if it does not define at least the
* required number of parameters.
*
* Processing time is linear with respect to the number of members of the
* provided interface.
*
* @param {Interface} iface interface that must be adhered to
* @param {Object} obj object to check compatibility against
*
* @return {Array.<Array.<string, string>>} compatibility reasons
*/
function analyzeCompat( iface, obj )
{
var missing = [];
util.propParse( iface.prototype, {
method: function( name, func, is_abstract, keywords )
{
if ( typeof obj[ name ] !== 'function' )
{
missing.push( [ name, 'missing' ] );
}
else if ( obj[ name ].length < func.__length )
{
// missing parameter(s); note that we check __length on the
// interface method (our internal length) but not on the
// object (since it may be a vanilla object)
missing.push( [ name, 'incompatible' ] );
}
},
} );
return missing;
}
/**
* Attaches instance check method
*
* This method is invoked when checking the type of a class against an
* interface.
*
* @param {Interface} iface interface that must be adhered to
*
* @return {undefined}
*/
function attachInstanceOf( iface )
{
util.defineSecureProp( iface, '__isInstanceOf', function( type, obj )
{
return _isInstanceOf( type, obj );
} );
}
/**
* Determine if INSTANCE implements the interface TYPE
*
* @param {Interface} type interface to check against
* @param {Object} instance instance to examine
*
* @return {boolean} whether TYPE is implemented by INSTANCE
*/
function _isInstanceOf( type, instance )
{
// if no metadata are available, then our remaining checks cannot be
// performed
if ( !instance.__cid || !( meta = ClassBuilder.getMeta( instance ) ) )
{
return false;
}
implemented = meta.implemented;
i = implemented.length;
// check implemented interfaces et. al. (other systems may make use of
// this meta-attribute to provide references to types)
while ( i-- )
{
if ( implemented[ i ] === type )
{
return true;
}
}
return false;
}
module.exports.isInstanceOf = _isInstanceOf;

View File

@ -0,0 +1,405 @@
/**
* Tests class interoperability with vanilla ECMAScript
*
* Copyright (C) 2014 Mike Gerwitz
*
* This file is part of GNU ease.js.
*
* ease.js is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* Note that these tests all use the `new' keyword for instantiating
* classes, even though it is not required with ease.js; this is both for
* historical reasons (when `new' was required during early development) and
* because we are not testing (and do want to depend upon) that feature.
*/
require( 'common' ).testCase(
{
caseSetUp: function()
{
this.Class = this.require( 'class' );
},
/**
* While this may seem at odds with ease.js' philosophy (because ease.js
* methods are *not* virtual by default), we do not have much choice in
* the matter: JavaScript is very lax and does not offer a way to
* declare something as virtual or otherwise. Given that, we have to
* choose between implicit virtual methods, or never allowing the user
* to override methods inherited from a prototype. The latter is not a
* wise choice, since there would be no way to change that behavior.
*
* Of course, if such a distinction were important, a wrapper class
* could be created that simply extends the prototype, marks methods
* virtual as appropriate, and retain only that reference for use from
* that point forward.
*/
'Methods inherited from a prototype are implicitly virtual': function()
{
var expected = {};
var P = function()
{
this.foo = function()
{
return null;
};
}
var Class = this.Class,
inst;
// if an error is thrown here, then we're probably not virtual
this.assertDoesNotThrow( function()
{
inst = Class.extend( P,
{
'override foo': function()
{
return expected;
}
} )();
} );
// the sky is falling if the above worked but this didn't
this.assertStrictEqual( inst.foo(), expected );
},
/**
* Complement to the above test.
*/
'Prototype method overrides must provide override keyword': function()
{
var P = function()
{
this.foo = function() {};
};
var Class = this.Class;
this.assertThrows( function()
{
Class.extend( P,
{
// missing override keyword
foo: function() {},
} );
} );
},
/**
* This was a subtle bug that creeped up in a class that was derived
* from a prototype: the prototype was setting its property values
* (which are of course public), which the class was also manipulating.
* Unfortunately, the class was manipulating a property of a same name
* on the private visibility object, whereas the prototype instance was
* manipulating it on the public. Therefore, the value of the property
* varied depending on whether you asked the class instance or the
* prototype instance that it inherited. Yikes.
*
* The root issue of this was even more subtle: the parent method (that
* does the manipulation) was invoked, meaning that it was executed
* within the context of the private visibility object, which is what
* caused the issue. However, this issue is still valid regardless of
* whether a parent method is called.
*
* Mitigating this is difficult, so we settle for a combination of good
* guessing and user education. We assume that all non-function fields
* set on the object (its own fields---not the prototype chain) by the
* constructor are public and therefore need to be proxied, and so
* implicitly declare them as such. Any remaining properties that are
* set on the object (e.g. set by methods but not initialized in the
* ctor) will need to be manually handled by declaring them as public in
* the class. We test the first case here.
*/
'Recognizes and proxies prototype properties as public': function()
{
var expected = 'baz',
expected2 = 'buzz';
// ctor initializes a single property, which is clearly public (as
// all fields on an object are)
var P = function()
{
this.foo = 'bar';
this.updateFoo = function( val )
{
this.foo = val;
};
};
var inst = this.Class.extend( P,
{
// since updateField is invoked within the context of the
// instance's private visibility object (unless falling back),
// we need to ensure that the set of foo is properly proxied
// back to the public property
'override updateFoo': function( val )
{
// consider that we're now invoking the parent updateFoo
// within the context of the private visibility object,
// *not* the public visibility object that it is accustomed
// to
this.__super( val );
return this;
},
ownUpdateFoo: function( val )
{
this.foo = val;
return this;
}
} )();
// if detection failed, then the value of foo will still be "bar"
this.assertEqual( inst.ownUpdateFoo( expected ).foo, expected );
// another interesting case; they should be mutual, but it's still
// worth demonstrating (see docblock comments)
this.assertEqual( inst.updateFoo( expected2 ).foo, expected2 );
},
/**
* This demonstrates what happens if ease.js is not aware of a
* particular property. This test ensures that the result is as
* expected.
*/
'Does not recognize non-ctor-initialized properties as public':
function()
{
var expected = 'bar';
var P = function()
{
this.init = function( val )
{
// this was not initialized in the ctor
this.foo = val;
return this;
};
};
var inst = this.Class.extend( P,
{
rmfoo: function()
{
// this is not proxied
this.foo = undefined;
return this;
},
getFoo: function()
{
return this.foo;
}
} )();
// the public foo and the foo visible inside the class are two
// different references, so rmfoo() will have had no effect on the
// public API
this.assertEqual(
inst.init( expected ).rmfoo().foo,
expected
);
// but it will be visible internally
this.assertEqual( inst.getFoo(), undefined );
},
/**
* In the case where ease.js is unable to do so automatically, we should
* be able to correct the proxy situation ourselves. This is where the
* aforementioned "education" part comes in; it will be documented in
* the manual.
*/
'Declaring non-ctor-initialized properties as public resolves proxy':
function()
{
var expected = 'bar';
var P = function()
{
this.init = function()
{
// this was not initialized in the ctor
this.foo = null;
return this;
};
};
var inst = this.Class.extend( P,
{
// the magic
'public foo': null,
setFoo: function( val )
{
this.foo = val;
return this;
}
} )();
this.assertEqual( inst.init().setFoo( expected ).foo, expected );
},
/**
* While this should follow as a conseuqence of the above, let's be
* certain, since it would re-introduce the problems that we are trying
* to avoid (not to mention it'd be inconsistent with OOP conventions).
*/
'Cannot de-escalate visibility of prototype properties': function()
{
var P = function() { this.foo = 'bar'; };
var Class = this.Class;
this.assertThrows( function()
{
Class.extend( P,
{
// de-escalate from public to protected
'protected foo': '',
} );
} );
},
/**
* This check is probably not necessary, but is added to prevent any
* potential regressions. This ensures that public methods on the
* prototype will always return the public visibility object---and they
* would anyway, since that's the context in which they are invoked
* through the public API.
*
* The only other concern is that when they are invoked by other ease.js
* methods, then they are passed the private member object as the
* context. In this case, however, the return value is passed back to
* the caller (the ease.js method), which properly handles returning the
* public member object instead.
*/
'Returning `this` from prototype method yields public obj': function()
{
var P = function()
{
// when invoked by an ease.js method, is passed private member
// object
this.pub = function() { return this; }
};
var inst = this.Class.extend( P, {} )();
// should return itself; we should not have modified that behavior
this.assertStrictEqual( inst.pub(), inst );
},
/**
* When prototypally extending a class, it is not wise to invoke the
* constructor (just like ease.js does not invoke the constructor of
* subtypes until the supertype is instantiated), as the constructor may
* validate its arguments, or may even have side-effects. Expose this
* internal deferral functionality for our prototypal friends.
*
* It is incredibly unwise to use this function purely to circumvent the
* constructor, as classes will use the constructor to ensure that the
* inststance is in a consistent and expected state.
*
* This may also have its uses for stubbing/mocking.
*/
'Can defer invoking __construct': function()
{
var expected = {};
var C = this.Class(
{
__construct: function()
{
throw Error( "__construct called!" );
},
foo: function() { return expected; },
} );
var inst;
this.assertDoesNotThrow( function()
{
inst = C.asPrototype();
} );
// should have instantiated C without invoking its constructor
this.assertOk( this.Class.isA( C, inst ) );
// we should be able to invoke methods even though the ctor has not
// yet run
this.assertStrictEqual( expected, inst.foo() );
},
/**
* Ensure that the prototype is able to invoke the deferred constructor.
* Let's hope they actually do. This should properly bind the context to
* whatever was provided; it should not be overridden. But see the test
* case below.
*/
'Can invoke constructor within context of prototypal subtype':
function()
{
var expected = {};
var C = this.Class(
{
foo: null,
__construct: function() { this.foo = expected; },
} );
function SubC() { this.__construct.call( this ); }
SubC.prototype = C.asPrototype();
this.assertStrictEqual(
( new SubC() ).foo,
expected
);
},
/**
* Despite being used as part of a prototype, it's important that
* ease.js' context switching between visibility objects remains active.
*/
'Deferred constructor still has access to private context': function()
{
var expected = {};
var C = this.Class(
{
'private _foo': null,
__construct: function() { this._foo = expected; },
getFoo: function() { return this._foo },
} );
function SubC() { this.__construct.call( this ); }
SubC.prototype = C.asPrototype();
this.assertStrictEqual(
( new SubC() ).getFoo(),
expected
);
},
} );

View File

@ -0,0 +1,91 @@
/**
* Tests treatment of class instances
*
* Copyright (C) 2014 Mike Gerwitz
*
* This file is part of GNU ease.js.
*
* ease.js is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
require( 'common' ).testCase(
{
caseSetUp: function()
{
this.Sut = this.require( 'ClassBuilder' );
},
/**
* Instance check delegation helps to keep ease.js extensible and more
* loosely coupled. If the given type implements a method
* __isInstanceOf, it will be invoked and its return value will be the
* result of the entire expression.
*/
'Delegates to type-specific instance method if present': function()
{
var _self = this;
// object to assert against
var obj = {};
// mock type
var type = { __isInstanceOf: function( givent, giveno )
{
_self.assertStrictEqual( givent, type );
_self.assertStrictEqual( giveno, obj );
called = true;
return true;
} };
this.assertOk( this.Sut.isInstanceOf( type, obj ) );
this.assertOk( called );
},
/**
* In the event that the provided type does not provide any instance
* check method, we shall fall back to ECMAScript's built-in instanceof
* operator.
*/
'Falls back to ECMAScript instanceof check lacking type method':
function()
{
// T does not define __isInstanceOf
var T = function() {},
o = new T();
this.assertOk( this.Sut.isInstanceOf( T, o ) );
this.assertOk( !( this.Sut.isInstanceOf( T, {} ) ) );
},
/**
* The instanceof operator will throw an exception if the second operand
* is not a function. Our fallback shall not do that---it shall simply
* return false.
*/
'Fallback does not throw exception if type is not a constructor':
function()
{
var _self = this;
this.assertDoesNotThrow( function()
{
// type is not a ctor; should just return false
_self.assertOk( !( _self.Sut.isInstanceOf( {}, {} ) ) );
} );
},
} );

View File

@ -0,0 +1,157 @@
/**
* Tests interface interoperability with vanilla ECMAScript
*
* Copyright (C) 2014 Free Software Foundation, Inc.
*
* This file is part of GNU ease.js.
*
* GNU ease.js is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
require( 'common' ).testCase(
{
caseSetUp: function()
{
this.Sut = this.require( 'interface' );
this.I = this.Sut(
{
foo: [ 'a', 'b' ],
bar: [ 'a' ],
} );
},
/**
* Not all developers will wish to use ease.js, even if the library they
* are interfacing with does. In the case of interfaces, this isn't
* particularity important. To understand why, consider the three main
* reasons why interfaces would be used: (1) to ensure that an object
* conforms to a defined API; (2) to permit polymorphism; and (3) to
* denote intent of use, meaning that even though a Basketball and Gun
* may both implement a `shoot' method, they are not intended to be used
* in the same context, even if both of them can be `shot'.
*
* Prototypes in JavaScript, without aid of a static analysis tool,
* generally rely on duck typing to enforce interfaces. In this sense,
* (3) can be sacrificed for the sake of interop but it's still
* important when working with ease.js classes). Since (2) follows as a
* consequence of (1), we need only a way to ensure that the API of the
* prototype is compatible with the named interface. In ease.js, this is
* is quick: the implemented interfaces are cached. With prototypes,
* even though it's not as efficient, we can still check that each of
* the methods named in the interface exist and are compatible (have the
* proper number of arguments).
*
* This has two powerful consequences: (1) external code can interface
* with ease.js without having to buy into its class/interface system;
* and (2) interfaces can be created to represent existing
* objects/prototypes (e.g. W3C DOM APIs).
*/
'Prototype instances and objects can conform to interfaces': function()
{
// conforming prototype
function P() {};
P.prototype = {
foo: function( a, b ) {},
bar: function( a ) {},
};
// instance should therefore be conforming
this.assertOk( this.I.isCompatible( new P() ) );
// ah but why stop there? (note that this implies that *any* object,
// prototype or not, can conform to an interface)
this.assertOk( this.I.isCompatible( P.prototype ) );
},
/**
* The entire point of interfaces is to ensure that a specific API is in
* place; methods are the core component of this.
*/
'Objects missing methods are non-conforming': function()
{
// missing method
function P() {};
P.prototype = {
foo: function( a, b ) {},
};
this.assertOk( !( this.I.isCompatible( new P() ) ) );
this.assertOk( !( this.I.isCompatible( P.prototype ) ) );
},
/**
* ease.js enforces parameter count so that implementers are cognisant
* of the requirements of the API. We have two cases to consider here:
* (1) that an external prototype is attempting to conform to an ease.js
* interface; or (2) that an interface is being developed for an
* existing external prototype. In the former case, the user has control
* over the parameter list. In the latter case, the interface designer
* can design an interface that requires only the most common subset of
* parameters, or none at all.
*/
'Methods missing parameters are non-conforming': function()
{
// missing second param (at this point, we know prototype traversal
// works, so we will just use any 'ol object)
var obj = { foo: function( a ) {} },
I = this.Sut( { foo: [ 'a', 'b' ] } );
this.assertOk( !( I.isCompatible( obj ) ) );
},
/**
* This test is consistent with ease.js' functionality.
*/
'Methods are still compatible with extra parameters': function()
{
// extra param is okay
var obj = { foo: function( a, b, c ) {} },
I = this.Sut( { foo: [ 'a', 'b' ] } );
this.assertOk( I.isCompatible( obj ) );
},
/**
* This should go without explanation.
*/
'Interface methods must be implemented as functions': function()
{
// not a function
var obj = { foo: {} },
I = this.Sut( { foo: [] } );
this.assertOk( !( I.isCompatible( obj ) ) );
},
/**
* Interfaces define only an API that must exist; it does not restrict a
* more rich API.
*/
'Additional methods do not trigger incompatibility': function()
{
// extra methods are okay
var obj = { foo: function() {}, bar: function() {} },
I = this.Sut( { foo: [] } );
this.assertOk( I.isCompatible( obj ) );
},
} );