diff --git a/doc/Makefile.am b/doc/Makefile.am index 2d53bdd..20629eb 100644 --- a/doc/Makefile.am +++ b/doc/Makefile.am @@ -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 diff --git a/doc/easejs.texi b/doc/easejs.texi index 101b649..9434f7e 100644 --- a/doc/easejs.texi +++ b/doc/easejs.texi @@ -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 diff --git a/doc/interop.texi b/doc/interop.texi new file mode 100644 index 0000000..8952e0c --- /dev/null +++ b/doc/interop.texi @@ -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. + diff --git a/lib/ClassBuilder.js b/lib/ClassBuilder.js index 0b760aa..69c426b 100644 --- a/lib/ClassBuilder.js +++ b/lib/ClassBuilder.js @@ -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 ) diff --git a/lib/MemberBuilder.js b/lib/MemberBuilder.js index c1c67fc..777fcf6 100644 --- a/lib/MemberBuilder.js +++ b/lib/MemberBuilder.js @@ -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 * diff --git a/lib/Trait.js b/lib/Trait.js index e92a973..bb3cf86 100644 --- a/lib/Trait.js +++ b/lib/Trait.js @@ -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; }; diff --git a/lib/interface.js b/lib/interface.js index 9a1e3a2..28fd3bc 100644 --- a/lib/interface.js +++ b/lib/interface.js @@ -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.>} 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; + diff --git a/test/Class/InteropTest.js b/test/Class/InteropTest.js new file mode 100644 index 0000000..1bf75a0 --- /dev/null +++ b/test/Class/InteropTest.js @@ -0,0 +1,415 @@ +/** + * 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 . + * + * 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' ); + this.fallback = this.require( 'util' ).definePropertyFallback(); + }, + + + /** + * 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. + * + * This does not apply in the case of a fallback, because there are not + * separate visibility objects in that case. + */ + 'Does not recognize non-ctor-initialized properties as public': + function() + { + if ( this.fallback ) + { + // no separate visibility layers; does not apply + return; + } + + 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 + ); + }, +} ); + diff --git a/test/ClassBuilder/InstanceTest.js b/test/ClassBuilder/InstanceTest.js new file mode 100644 index 0000000..fd2ac78 --- /dev/null +++ b/test/ClassBuilder/InstanceTest.js @@ -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 . + */ + +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( {}, {} ) ) ); + } ); + }, +} ); + diff --git a/test/Interface/InteropTest.js b/test/Interface/InteropTest.js new file mode 100644 index 0000000..18b3596 --- /dev/null +++ b/test/Interface/InteropTest.js @@ -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 . + */ + +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 ) ); + }, +} ); +