diff --git a/lib/Trait.js b/lib/Trait.js index b622586..260a9d2 100644 --- a/lib/Trait.js +++ b/lib/Trait.js @@ -97,11 +97,23 @@ function _createStaging( name ) } -Trait.extend = function( dfn ) +Trait.extend = function() { // we may have been passed some additional metadata var meta = ( this || {} ).__$$meta || {}; + var a = arguments, + an = a.length; + + if ( an > 2 ) + { + throw Error( "Trait.extend expects no more than two arguments" ); + } + + // this verbose syntax ensures that `arguments' isn't passed around + var dfn = ( ( an > 0 ) ? a[ an - 1 ] : 0 ) || {}, + extend = ( ( an > 1 ) ? a[ an - 2 ] : 0 ) || null; + // store any provided name, since we'll be clobbering it (the definition // object will be used to define the hidden abstract class) var name = dfn.__name || '(Trait)', @@ -171,6 +183,7 @@ Trait.extend = function( dfn ) Trait.__trait = type; Trait.__acls = tclass; Trait.__ccls = null; + Trait.__extend = extend; Trait.toString = function() { return ''+name; @@ -530,12 +543,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 ) { + _chkTraitExtend( trait, base ); + // the abstract class hidden within the trait var acls = trait.__acls; @@ -567,6 +582,40 @@ function mixin( trait, dfn, tc, base ) } +/** + * Throw an exception if the provided base does not satisfy the mixin + * requirement + * + * When traits extend types, then they may only be mixed into that type or + * one of its subtypes. + * + * @param {Trait} trait mixin + * @param {Object} base target object + * + * @return {undefined} + */ +function _chkTraitExtend( trait, base ) +{ + var extend = trait.__extend; + + if ( !extend ) + { + return; + } + + if ( !( + ( extend === base ) + || ClassBuilder.isInstanceOf( extend, base.prototype ) + ) ) + { + throw TypeError( + "Trait " + trait.toString() + " cannot be mixed into " + + base.toString() + "; expected type " + extend.toString() + ); + } +} + + /** * Recursively mix in class methods * @@ -851,4 +900,3 @@ function _tctorApply() module.exports = Trait; - diff --git a/test/Trait/ClassExtendTest.js b/test/Trait/ClassExtendTest.js new file mode 100644 index 0000000..58b8480 --- /dev/null +++ b/test/Trait/ClassExtendTest.js @@ -0,0 +1,215 @@ +/** + * Tests extending traits from classes + * + * Copyright (C) 2014 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' ); + }, + + + /** + * 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