diff --git a/README.traits b/README.traits index 772978a..38813aa 100644 --- a/README.traits +++ b/README.traits @@ -12,12 +12,11 @@ complete. * TODO Trait Extending - Currently, the only way for a trait to override methods of a class - it is being mixed into is to implement a common interface. Traits - should alternatively be able to "extend" classes, which will have - effects similar to Scala in that the trait can only be mixed into - that class. Further, traits should be able to extend and mix in - other traits (though such should be done conservatively). + Traits are able to "extend" classes, thereby declaring interface + compatability; this is a useful alternative to interfaces when a trait is + designed to augment a specific type. This convenience should be extended + to traits: a trait should be able to "extend" another trait in the same + manner that it may extend a class. * TODO Documentation Due to the trait implementation taking longer than expected to @@ -78,11 +77,5 @@ complete. improvement. Until that time, be mindful of the performance test cases in the =test/perf= directory. -* TODO Intermediate object as class - The immediate syntax---=Foo.use(T)()=---is a short-hand equivalent - of =Foo.use(T).extend({})()=. As such, for consistency, =Class.isA= - should consider the intermediate object returned by a call to =use= - to be a class. - If we are to do so, though, we must make sure that the entire class API is supported. diff --git a/lib/ClassBuilder.js b/lib/ClassBuilder.js index 06bac0b..c214a5c 100644 --- a/lib/ClassBuilder.js +++ b/lib/ClassBuilder.js @@ -144,15 +144,6 @@ function ClassBuilder( warn_handler, member_builder, visibility_factory ) */ this._instanceId = 0; - /** - * Set to TRUE when class is in the process of being extended to ensure that - * a constructor can be instantiated (to use as the prototype) without - * invoking the class construction logic - * - * @type {boolean} - */ - this._extending = false; - /** * A flag to let the system know that we are currently attempting to access * a static property from within a method. This means that the caller should @@ -242,6 +233,30 @@ exports.getMeta = function( cls ) } +/** + * Allow OBJ to assume an identity as a class + * + * This is useful to use objects in situations where classes are expected, + * as it eliminates the need for handling of special cases. + * + * This is intended for internal use---there are no guarantees as to what + * methods ease.js may expect that a class-like object incorporate. That + * guarantee may exist in the future, but until then, stay away. + * + * @param {Object} obj object to masquerade as an ease.js class + * + * @return {Object} OBJ + */ +exports.masquerade = function( obj ) +{ + // XXX: this is duplicated; abstract + util.defineSecureProp( obj, _priv, {} ); + + createMeta( obj, exports.ClassBase ); + return obj; +}; + + /** * Determines if the class is an instance of the given type * @@ -321,9 +336,6 @@ exports.prototype.build = function extend( _, __ ) { var build = this; - // ensure we'll be permitted to instantiate abstract classes for the base - this._extending = true; - var a = arguments, an = a.length, props = ( ( an > 0 ) ? a[ an - 1 ] : 0 ) || {}, @@ -339,18 +351,19 @@ exports.prototype.build = function extend( _, __ ) props: this._memberBuilder.initMembers(), }, - meta = exports.getMeta( base ) || {}, + // constructor may be different than base + pmeta = exports.getMeta( prototype.constructor ) || {}, abstract_methods = - util.clone( meta.abstractMethods ) + util.clone( pmeta.abstractMethods ) || { __length: 0 }, virtual_members = - util.clone( meta.virtualMembers ) + util.clone( pmeta.virtualMembers ) || {} ; - // prevent extending final classes + // prevent extending final classes (TODO: abstract this check) if ( base.___$$final$$ === true ) { throw Error( @@ -464,7 +477,7 @@ exports.prototype.build = function extend( _, __ ) util.defineSecureProp( prototype, '__self', new_class.___$$svis$$ ); // create internal metadata for the new class - var meta = createMeta( new_class, base ); + var meta = createMeta( new_class, base, pmeta ); meta.abstractMethods = abstract_methods; meta.virtualMembers = virtual_members; meta.name = cname; @@ -476,15 +489,13 @@ exports.prototype.build = function extend( _, __ ) // (intended for use in prototype chains) new_class.asPrototype = function() { - build._extending = true; - var inst = new_class(); - build._extending = false; + new_class[ _priv ].extending = true; + var inst = new new_class(); + new_class[ _priv ].extending = false; + return inst; }; - // we're done with the extension process - this._extending = false; - return new_class; }; @@ -498,7 +509,9 @@ exports.prototype._getBase = function( base ) // constructor (we could also check to ensure that the return value of // the constructor is an object, but that is not our concern) case 'function': - return new base(); + return ( base[ _priv ] ) + ? base.asPrototype() + : new base(); // we can use objects as the prototype directly case 'object': @@ -841,8 +854,8 @@ exports.prototype.createCtor = function( cname, abstract_methods, members ) */ exports.prototype.createConcreteCtor = function( cname, members ) { - var args = null, - _self = this; + var args = null, + _self = this; /** * Constructor function to be returned @@ -872,7 +885,7 @@ exports.prototype.createConcreteCtor = function( cname, members ) // If we're extending, we don't actually want to invoke any class // construction logic. The above is sufficient to use this class in a // prototype, so stop here. - if ( _self._extending ) + if ( ClassInstance[ _priv ].extending ) { return; } @@ -968,7 +981,7 @@ exports.prototype.createAbstractCtor = function( cname ) var __abstract_self = function() { - if ( !_self._extending ) + if ( !__abstract_self[ _priv ].extending ) { throw Error( "Abstract class " + ( cname || '(anonymous)' ) + @@ -1255,18 +1268,23 @@ exports.prototype.attachStatic = function( ctor, members, base, inheriting ) /** * Initializes class metadata for the given class * + * DYNMETA is used only when CPARENT's metadata are flagged as "lazy", + * meaning that the data are not available at the time of its definition, + * but are available now as DYNMETA. + * * @param {Function} func class to initialize metadata for * @param {Function} cparent class parent + * @param {?Object} dynmeta dynamic metadata * * @return {undefined} * * Suppressed due to warnings for use of __cid * @suppress {checkTypes} */ -function createMeta( func, cparent ) +function createMeta( func, cparent, dynmeta ) { var id = func.__cid, - parent_meta = ( ( cparent.__cid ) + parent_meta = ( cparent[ _priv ] ? exports.getMeta( cparent ) : undefined ); @@ -1274,7 +1292,13 @@ function createMeta( func, cparent ) // copy the parent prototype's metadata if it exists (inherit metadata) if ( parent_meta ) { - return func[ _priv ].meta = util.clone( parent_meta, true ); + return func[ _priv ].meta = util.clone( + // "lazy" metadata are unavailable at the time of definition + parent_meta._lazy + ? dynmeta + : parent_meta, + true + ); } // create empty @@ -1461,4 +1485,3 @@ function attachFlags( ctor, props ) // (v8 performance) props.___$$final$$ = props.___$$abstract$$ = undefined; } - diff --git a/lib/Trait.js b/lib/Trait.js index b622586..2545147 100644 --- a/lib/Trait.js +++ b/lib/Trait.js @@ -97,8 +97,50 @@ function _createStaging( name ) } -Trait.extend = function( dfn ) +Trait.extend = function( /* ... */ ) { + var an = arguments.length, + dfn = arguments[ an - 1 ], + has_ext_base = ( an > 1 ), + ext_base = ( has_ext_base ) ? arguments[ 0 ] : null; + + if ( an > 2 ) + { + throw Error( + "Unexpected number of arguments to Trait.extend" + ); + } + + if ( has_ext_base ) + { + var basetype = typeof ext_base; + + if ( ( ext_base === null ) + || !( ( basetype === 'object' ) + || ( basetype === 'function' ) + ) ) + { + throw TypeError( + "Trait cannot extend base of type '" + basetype + "'" + ); + } + + // prevent extending final classes (TODO: abstract this check; see + // also ClassBuilder) + if ( ext_base.___$$final$$ === true ) + { + throw TypeError( + "Trait cannot extend final class" + ); + } + + // TODO: this is intended to be temporary; see Trait/ClassExtendTest + if ( module.exports.isTrait( ext_base ) ) + { + throw TypeError( "Traits cannot extend other traits" ); + } + } + // we may have been passed some additional metadata var meta = ( this || {} ).__$$meta || {}; @@ -168,10 +210,11 @@ Trait.extend = function( dfn ) // and here we can see that traits are quite literally abstract classes var tclass = base.extend( dfn ); - Trait.__trait = type; - Trait.__acls = tclass; - Trait.__ccls = null; - Trait.toString = function() + Trait.__trait = type; + Trait.__acls = tclass; + Trait.__ccls = null; + Trait.__extbase = ext_base; + Trait.toString = function() { return ''+name; }; @@ -198,6 +241,41 @@ Trait.extend = function( dfn ) }; +/** + * Validate whether mixin is permitted + * + * If a mixee (the trait being mixed in) extends some type S, then a + * contract has been created mandating that that trait may only be mixed + * into something of type S; a `TypeError` will be thrown if this contract + * is violated. + * + * @param {Class} base mixor (target of mixin) + * @param {Trait} T mixee (trait being mixed in) + * + * @return {undefined} + * + * @throws {TypeError} on type contract violation + */ +function _validateMixin( base, T ) +{ + if ( !T.__extbase ) + { + return; + } + + // TODO: isSubtypeOf + if ( !( ( T.__extbase === base ) + || ClassBuilder.isInstanceOf( T.__extbase, base.asPrototype() ) + ) ) + { + throw TypeError( + "Cannot mix trait " + T.toString() + " into " + base.toString() + + "; mixor must be of type " + T.__extbase.toString() + ); + } +} + + /** * Retrieve a string representation of the trait type * @@ -530,12 +608,14 @@ function createVirtProxy( acls, dfn ) * @param {Trait} trait trait to mix in * @param {Object} dfn definition object to merge into * @param {Array} tc trait class context - * @param {Class} base target supertyep + * @param {Class} base target supertype * * @return {Object} dfn */ function mixin( trait, dfn, tc, base ) { + _validateMixin( base, trait ); + // the abstract class hidden within the trait var acls = trait.__acls; diff --git a/lib/class.js b/lib/class.js index d7b3884..dc13e98 100644 --- a/lib/class.js +++ b/lib/class.js @@ -34,6 +34,7 @@ var _console = ( typeof console !== 'undefined' ) ? console : undefined; var util = require( './util' ), ClassBuilder = require( './ClassBuilder' ), + Interface = require( './interface' ), warn = require( './warn' ), Warning = warn.Warning, @@ -179,11 +180,6 @@ module.exports.isClass = function( obj ) { obj = obj || _dummyclass; - if ( !obj.prototype ) - { - return false; - } - var meta = ClassBuilder.getMeta( obj ); // TODO: we're checking a random field on the meta object; do something @@ -477,6 +473,11 @@ function createImplement( base, ifaces, cname ) * be explicit: in this case, any instantiation attempts will result in an * exception being thrown. * + * This staging object may be used as a base for extending. Note, however, + * that its metadata are unavailable at the time of definition---its + * contents are marked as "lazy" and must be processed using the mixin's + * eventual metadata. + * * @param {function()} basef returns base from which to lazily * extend * @param {Array.} traits traits to mix in @@ -491,6 +492,13 @@ function createUse( basef, traits, nonbase ) // invoking the partially applied class will immediately complete its // definition and instantiate it with the provided constructor arguments var partial = function() + { + return partialClass() + .apply( null, arguments ); + }; + + + var partialClass = function() { // this argument will be set only in the case where an existing // (non-base) class is extended, meaning that an explict Class or @@ -503,8 +511,7 @@ function createUse( basef, traits, nonbase ) ); } - return createMixedClass( basef(), traits ) - .apply( null, arguments ); + return createMixedClass( basef(), traits ); }; @@ -550,6 +557,20 @@ function createUse( basef, traits, nonbase ) return partial.extend( {} ); }; + partial.asPrototype = function() + { + return partialClass().asPrototype(); + }; + + partial.__isInstanceOf = Interface.isInstanceOf; + + // allow the system to recognize this object as a viable base for + // extending, but mark the metadata as lazy: since we defer all + // processing for mixins, we cannot yet know all metadata + // TODO: `_lazy' is a kluge + ClassBuilder.masquerade( partial ); + ClassBuilder.getMeta( partial )._lazy = true; + return partial; } diff --git a/test/Class/GeneralTest.js b/test/Class/GeneralTest.js index 65746a9..0b59bbe 100644 --- a/test/Class/GeneralTest.js +++ b/test/Class/GeneralTest.js @@ -24,7 +24,8 @@ require( 'common' ).testCase( { setUp: function() { - this.Sut = this.require( 'class' ); + this.Sut = this.require( 'class' ); + this.ClassBuilder = this.require( 'ClassBuilder' ); this.Foo = this.Sut.extend( { @@ -249,6 +250,21 @@ require( 'common' ).testCase( }, + /** + * There are cases---intended for internal use---where it is beneficial + * for an object to be treated as though it were actually a class. + */ + 'Any object may masquerade as a class': function() + { + var obj = {}; + + // XXX: tightly coupled logic here; refactor things + this.ClassBuilder.masquerade( obj ); + + this.assertOk( this.Sut.isClass( obj ) ); + }, + + /** * This really should be encapsulated, probably, but it does exist for * reference. diff --git a/test/Trait/AbstractTest.js b/test/Trait/AbstractTest.js index 01287a9..8b8777d 100644 --- a/test/Trait/AbstractTest.js +++ b/test/Trait/AbstractTest.js @@ -1,7 +1,7 @@ /** * Tests abstract trait definition and use * - * Copyright (C) 2014 Free Software Foundation, Inc. + * Copyright (C) 2015 Free Software Foundation, Inc. * * This file is part of GNU ease.js. * @@ -360,4 +360,70 @@ require( 'common' ).testCase( _self.Class.use( Ta ).extend(); } ); }, + + + /** + * Before traits, the only way to make an abstract class concrete, or + * vice versa, was by extending. Now, however, a mixing in a trait can + * introduce abstract or concrete methods. This poses a problem, since + * the syntax for providing self-documenting AbstractClass definitions + * no longer works: invoking `AbstractClass.use' produces different + * results than invoking `SomeAbstractClass.use', with the goal of + * extending it. + * + * Consider this issue: we wish to mix some trait T into abstract class + * AC. Trait T does not provide a concrete implementation of the + * abstract methods in AT, and so the resulting class after the final + * `#extend' call would be abstract. + * + * We have no choice but to allow extending the intermediate object + * produced by a class's `#use' method; otherwise, any call to `#extend' + * on the intermediate object would result in an error, because the + * class would still have abstract members, but has not been declared to + * be abstract. Handling abstract classes in this manner would be + * consistent with all other scenarios, and would be transparent: why + * should the user care that there is some odd intermediate object being + * used rather than an actual class? + */ + 'Abstract classes can be derived from intermediates': function() + { + var chk = [{}]; + + var AC = this.AbstractClass( { 'abstract foo': [] } ), + T = this.Sut( { moo: function() { return chk; } } ); + + // mix trait into an abstract class + var M = this.AbstractClass.extend( + AC.use( T ), + {} + ); + + this.assertOk( this.Class.isClass( M ) ); + this.assertOk( M.isAbstract() ); + + var inst = M.extend( { foo: function() {} } )(); + + // we should not have lost the original abstract class + this.assertOk( + this.Class.isA( AC, inst ) + ); + + // not strictly necessary; comfort/sanity check: if this succeeds + // but the next fails, then there's a problem marking the + // implemented types + this.assertStrictEqual( + chk, + inst.moo() + ); + + // the trait should have been applied (see above note if this + // fails); if this does fail, note that, without + // AbstractClass.extend, we have (correctly): + // isA( T, AC.use( T ).extend( ... )() ) + this.assertOk( + this.Class.isA( T, inst ), + 'Instance is not recognized as having mixed in type T, but ' + + 'incorporates its definition; metadata bug?' + ); + }, } ); diff --git a/test/Trait/ClassExtendTest.js b/test/Trait/ClassExtendTest.js new file mode 100644 index 0000000..345890c --- /dev/null +++ b/test/Trait/ClassExtendTest.js @@ -0,0 +1,279 @@ +/** + * Tests extending traits from classes + * + * Copyright (C) 2015 Free Software Foundation, Inc. + * + * 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( 'Trait' ); + this.Class = this.require( 'class' ); + this.AbstractClass = this.require( 'class_abstract' ); + this.FinalClass = this.require( 'class_final' ); + + // nonsensical extend bases that do not support object + // representations (TODO: use some system-wide understanding of + // "extendable" values) + this.nonsense = [ + null, + undefined, + false, + NaN, + Infinity, + -Infinity, + ]; + }, + + + /** + * Normally, there are no restrictions on what class a trait may be + * mixed into. When ``extending'' a class, we would expect intuitively + * that this behavior would remain consistent. + */ + 'Trait T extending class C can be mixed into C': function() + { + var C = this.Class( {} ), + T = this.Sut.extend( C, {} ); + + this.assertDoesNotThrow( function() + { + C.use( T )(); + } ); + }, + + + /** + * Restrictions emerge once a disjoint type D attempts to mix in a trait + * T extending class C. When C is ``extended'', we are + * effectively extracting and implementing interfaces representing its + * public and protected members---this has all the same effects that one + * would expect from implementing an interface. However, the act of + * extension implies a tight coupling between T and C: we're not just + * expecting a particular interface; we're also expecting the mixee to + * behave in a certain manner, just as a subtype of C would expect. + * + * Traits extending classes therefore behave like conventional subtypes + * extending their parents, but with a greater degree of + * flexibility. We would not expect to be able to use a subtype of C as + * if it were a disjoint type D, because they are different types: even + * if they share an identical interface, their intents are + * distinct. This is the case here. + */ + 'Trait T extending class C cannot be mixed into disjoint class D': + function() + { + var C = this.Class( {} ), + D = this.Class( {} ), + T = this.Sut.extend( C, {} ); + + this.assertThrows( function() + { + D.use( T )(); + }, TypeError ); + }, + + + /** + * Just as some class D' extending supertype D is of both types D' and + * D, and a trait T implementing interface I is of both types T and I, + * we would expect that a trait T extending C would be of both types T + * _and_ C, since T is effectively implementing C's interface. + */ + 'Trait T extending class C is of both types T and C': function() + { + var C = this.Class( {} ), + T = this.Sut.extend( C, {} ), + inst = C.use( T )(); + + this.assertOk( this.Class.isA( T, inst ) ); + this.assertOk( this.Class.isA( C, inst ) ); + }, + + + /** + * Since a subtype C2 is, by definition, also of type C, we would expect + * that any traits that are valid to be mixed into type C would also be + * valid to be mixed into subtypes of C. This permits trait + * polymorphism in the same manner as classes and interfaces. + */ + 'Trait T extending class C can be mixed into C subtype C2': function() + { + var C = this.Class( {} ), + C2 = C.extend( {} ), + T = this.Sut.extend( C, {} ); + + this.assertDoesNotThrow( function() + { + C2.use( T )(); + } ); + }, + + + /** + * This is a corollary of the above associations. + */ + 'Trait T extending subtype C2 cannot be mixed into supertype C': + function() + { + var C = this.Class( {} ), + C2 = C.extend( {} ), + T = this.Sut.extend( C2, {} ); + + this.assertThrows( function() + { + C.use( T )(); + }, TypeError ); + }, + + + /** + * The trait `#extend' method mirrors the syntax of classes: the first + * argument is the class to be extended, and the second is the actual + * definition. + */ + 'Trait definition can follow class extension': function() + { + var a = ['a'], + b = ['b']; + + var C = this.Class( { + foo: function() { return a; } + } ), + T = this.Sut.extend( C, { + bar: function() { return b; } + } ); + + var inst = C.use( T )(); + + this.assertStrictEqual( inst.foo(), a ); + this.assertStrictEqual( inst.bar(), b ); + }, + + + /** + * This is a corollary, but is still worth testing for assurance. + * + * We already stated that a trait Tb extending C's subtype C2 cannot be + * mixed into C, because C is not of type C2. But Ta extending C can be + * mixed into C2, because C2 _is_ of type C. Therefore, both of these + * traits should be able to co-mix in the latter situation, but not the + * former. + */ + 'Trait Ta extending C and Tb extending C2 cannot co-mix': function() + { + var C = this.Class( 'C' ).extend( { _a: null } ), + C2 = this.Class( 'C2' ).extend( C, { _b: null } ), + Ta = this.Sut.extend( C, {} ), + Tb = this.Sut.extend( C2, {} ); + + // this is _not_ okay + this.assertThrows( function() + { + C.use( Ta ).use( Tb )(); + } ); + + // but this is, since Tb extends C2 itself, and Ta extends C2's + // supertype + this.assertDoesNotThrow( function() + { + C2.use( Tb ).use( Ta )(); + } ); + }, + + + /** + * The `#extend' method for traits, when extending a class, must not + * accept more than two arguments; otherwise, there may be a bug. It + * does not make sense to accept more arguments, since traits can only + * extend a single class. + * + * The reason? Well, as a corollary of the above, given types + * C_0,...,C_n to extend: C_x, 0<=x