From 4605476b4fdb67c6a6da9f60197d2bff09b92344 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Mon, 21 Apr 2014 02:00:52 -0400 Subject: [PATCH 1/8] Added isCompatible method to interfaces There is a great amount of rationale in the test case added in this commit. --- lib/interface.js | 85 ++++++++++++++++++ test/Interface/InteropTest.js | 157 ++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 test/Interface/InteropTest.js diff --git a/lib/interface.js b/lib/interface.js index 9a1e3a2..4f7e767 100644 --- a/lib/interface.js +++ b/lib/interface.js @@ -268,6 +268,7 @@ var extend = ( function( extending ) attachExtend( new_interface ); attachStringMethod( new_interface, iname ); + attachCompat( new_interface ); new_interface.prototype = prototype; new_interface.constructor = new_interface; @@ -376,3 +377,87 @@ 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; +} + 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 ) ); + }, +} ); + From aa0003d2391f3f4f0b46b58a92e12cc7ee9a6420 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Tue, 22 Apr 2014 00:24:21 -0400 Subject: [PATCH 2/8] ClassBuilder.isInstanceOf now defers to type This allows separation of concerns and makes the type system extensible. If the type does not implement the necessary API, it falls back to using instanceof. --- lib/ClassBuilder.js | 47 ++++++++-------- lib/Trait.js | 7 ++- lib/interface.js | 60 +++++++++++++++++++- test/ClassBuilder/InstanceTest.js | 91 +++++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 26 deletions(-) create mode 100644 test/ClassBuilder/InstanceTest.js diff --git a/lib/ClassBuilder.js b/lib/ClassBuilder.js index 0b760aa..c52558c 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; -}; +} /** 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 4f7e767..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' );; /** @@ -269,6 +269,7 @@ 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; @@ -461,3 +462,58 @@ function analyzeCompat( iface, obj ) 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/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( {}, {} ) ) ); + } ); + }, +} ); + From 7f3e7fba352c3824ac9e1757444f42280073a516 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Wed, 23 Apr 2014 01:50:35 -0400 Subject: [PATCH 3/8] Overriding vanilla prototype methods no longer errors This is something that I've been aware of for quite some time, but never got around to fixing; ease.js had stalled until it was revitalized by becoming a GNU project. --- lib/MemberBuilder.js | 14 +++++- test/Class/InteropTest.js | 102 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 test/Class/InteropTest.js 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/test/Class/InteropTest.js b/test/Class/InteropTest.js new file mode 100644 index 0000000..b66f348 --- /dev/null +++ b/test/Class/InteropTest.js @@ -0,0 +1,102 @@ +/** + * 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' ); + }, + + + /** + * 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() {}, + } ); + } ); + }, +} ); + From 34d84412e17c30dbf65886b68c106e1f22f8b48d Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Wed, 23 Apr 2014 02:01:02 -0400 Subject: [PATCH 4/8] Prototype supertype property proxy fix This was a nasty bug that I discovered when working on a project at work probably over a year ago. I had worked around it, but ease.js was largely stalled at the time; with it revitalized by GNU, it's finally getting fixed. See test case comments for more information. --- lib/ClassBuilder.js | 57 +++++++++++ test/Class/InteropTest.js | 192 +++++++++++++++++++++++++++++++++++++- 2 files changed, 248 insertions(+), 1 deletion(-) diff --git a/lib/ClassBuilder.js b/lib/ClassBuilder.js index c52558c..343f513 100644 --- a/lib/ClassBuilder.js +++ b/lib/ClassBuilder.js @@ -360,6 +360,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 @@ -465,6 +473,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/test/Class/InteropTest.js b/test/Class/InteropTest.js index b66f348..ee1ce29 100644 --- a/test/Class/InteropTest.js +++ b/test/Class/InteropTest.js @@ -28,7 +28,8 @@ require( 'common' ).testCase( { caseSetUp: function() { - this.Class = this.require( 'class' ); + this.Class = this.require( 'class' ); + this.fallback = this.require( 'util' ).definePropertyFallback(); }, @@ -98,5 +99,194 @@ require( 'common' ).testCase( } ); } ); }, + + + /** + * 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': '', + } ); + } ); + }, } ); From 1fe9aa2c0cf7983fd1b01f98e951fcb08b7bd0df Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Fri, 25 Apr 2014 01:52:18 -0400 Subject: [PATCH 5/8] Test case to protect agaist retval regressions when extending prototypes --- test/Class/InteropTest.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/Class/InteropTest.js b/test/Class/InteropTest.js index ee1ce29..0b6950a 100644 --- a/test/Class/InteropTest.js +++ b/test/Class/InteropTest.js @@ -288,5 +288,34 @@ require( 'common' ).testCase( } ); } ); }, + + + /** + * 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 ); + }, } ); From fa177922b424381fa4a6ad377b38218454477a21 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Sat, 26 Apr 2014 10:00:01 -0400 Subject: [PATCH 6/8] Class#asPrototype support This is an interop feature: allows using ease.js classes as part of a prototype chain. --- lib/ClassBuilder.js | 12 +++++ test/Class/InteropTest.js | 94 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/lib/ClassBuilder.js b/lib/ClassBuilder.js index 343f513..69c426b 100644 --- a/lib/ClassBuilder.js +++ b/lib/ClassBuilder.js @@ -299,6 +299,8 @@ function _instChk( 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; @@ -445,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; diff --git a/test/Class/InteropTest.js b/test/Class/InteropTest.js index 0b6950a..1bf75a0 100644 --- a/test/Class/InteropTest.js +++ b/test/Class/InteropTest.js @@ -317,5 +317,99 @@ require( 'common' ).testCase( // 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 + ); + }, } ); From 8cfe231a63956a391847a0e4a901a3e0ee683c28 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Sat, 26 Apr 2014 11:09:21 -0400 Subject: [PATCH 7/8] Began work on interop chapter of manual --- doc/Makefile.am | 2 +- doc/easejs.texi | 2 + doc/interop.texi | 130 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 doc/interop.texi 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..07b4754 --- /dev/null +++ b/doc/interop.texi @@ -0,0 +1,130 @@ +@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:: +@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 + From 2a8965f17d7830e3ede59329cdc3d95c20754a58 Mon Sep 17 00:00:00 2001 From: Mike Gerwitz Date: Sun, 27 Apr 2014 02:09:49 -0400 Subject: [PATCH 8/8] Added section to manual on interoperable polymorphism --- doc/interop.texi | 237 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) diff --git a/doc/interop.texi b/doc/interop.texi index 07b4754..8952e0c 100644 --- a/doc/interop.texi +++ b/doc/interop.texi @@ -20,6 +20,7 @@ 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 @@ -128,3 +129,239 @@ chain: 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. +