Tôi có khá nhiều chức năng để cho phép các lớp được định nghĩa với nhiều kế thừa. Nó cho phép mã như sau. Nhìn chung, bạn sẽ lưu ý một sự khởi đầu hoàn toàn từ các kỹ thuật Phân loại gốc trong javascript (ví dụ: bạn sẽ không bao giờ thấy class
từ khóa):
let human = new Running({ name: 'human', numLegs: 2 });
human.run();
let airplane = new Flying({ name: 'airplane', numWings: 2 });
airplane.fly();
let dragon = new RunningFlying({ name: 'dragon', numLegs: 4, numWings: 6 });
dragon.takeFlight();
để tạo ra sản lượng như thế này:
human runs with 2 legs.
airplane flies away with 2 wings!
dragon runs with 4 legs.
dragon flies away with 6 wings!
Dưới đây là các định nghĩa lớp trông như thế nào:
let Named = makeClass('Named', {}, () => ({
init: function({ name }) {
this.name = name;
}
}));
let Running = makeClass('Running', { Named }, protos => ({
init: function({ name, numLegs }) {
protos.Named.init.call(this, { name });
this.numLegs = numLegs;
},
run: function() {
console.log(`${this.name} runs with ${this.numLegs} legs.`);
}
}));
let Flying = makeClass('Flying', { Named }, protos => ({
init: function({ name, numWings }) {
protos.Named.init.call(this, { name });
this.numWings = numWings;
},
fly: function( ){
console.log(`${this.name} flies away with ${this.numWings} wings!`);
}
}));
let RunningFlying = makeClass('RunningFlying', { Running, Flying }, protos => ({
init: function({ name, numLegs, numWings }) {
protos.Running.init.call(this, { name, numLegs });
protos.Flying.init.call(this, { name, numWings });
},
takeFlight: function() {
this.run();
this.fly();
}
}));
Chúng ta có thể thấy rằng mỗi định nghĩa lớp sử dụng makeClass
hàm chấp nhận một Object
tên lớp cha được ánh xạ tới các lớp cha. Nó cũng chấp nhận một hàm trả về một Object
thuộc tính chứa cho lớp được định nghĩa. Hàm này có một tham số protos
, chứa đủ thông tin để truy cập vào bất kỳ thuộc tính nào được xác định bởi bất kỳ lớp cha nào.
Phần cuối cùng được yêu cầu là makeClass
chính hàm, nó thực hiện khá nhiều công việc. Đây là, cùng với phần còn lại của mã. Tôi đã nhận xét makeClass
khá nhiều:
let makeClass = (name, parents={}, propertiesFn=()=>({})) => {
// The constructor just curries to a Function named "init"
let Class = function(...args) { this.init(...args); };
// This allows instances to be named properly in the terminal
Object.defineProperty(Class, 'name', { value: name });
// Tracking parents of `Class` allows for inheritance queries later
Class.parents = parents;
// Initialize prototype
Class.prototype = Object.create(null);
// Collect all parent-class prototypes. `Object.getOwnPropertyNames`
// will get us the best results. Finally, we'll be able to reference
// a property like "usefulMethod" of Class "ParentClass3" with:
// `parProtos.ParentClass3.usefulMethod`
let parProtos = {};
for (let parName in parents) {
let proto = parents[parName].prototype;
parProtos[parName] = {};
for (let k of Object.getOwnPropertyNames(proto)) {
parProtos[parName][k] = proto[k];
}
}
// Resolve `properties` as the result of calling `propertiesFn`. Pass
// `parProtos`, so a child-class can access parent-class methods, and
// pass `Class` so methods of the child-class have a reference to it
let properties = propertiesFn(parProtos, Class);
properties.constructor = Class; // Ensure "constructor" prop exists
// If two parent-classes define a property under the same name, we
// have a "collision". In cases of collisions, the child-class *must*
// define a method (and within that method it can decide how to call
// the parent-class methods of the same name). For every named
// property of every parent-class, we'll track a `Set` containing all
// the methods that fall under that name. Any `Set` of size greater
// than one indicates a collision.
let propsByName = {}; // Will map property names to `Set`s
for (let parName in parProtos) {
for (let propName in parProtos[parName]) {
// Now track the property `parProtos[parName][propName]` under the
// label of `propName`
if (!propsByName.hasOwnProperty(propName))
propsByName[propName] = new Set();
propsByName[propName].add(parProtos[parName][propName]);
}
}
// For all methods defined by the child-class, create or replace the
// entry in `propsByName` with a Set containing a single item; the
// child-class' property at that property name (this also guarantees
// there is no collision at this property name). Note property names
// prefixed with "$" will be considered class properties (and the "$"
// will be removed).
for (let propName in properties) {
if (propName[0] === '$') {
// The "$" indicates a class property; attach to `Class`:
Class[propName.slice(1)] = properties[propName];
} else {
// No "$" indicates an instance property; attach to `propsByName`:
propsByName[propName] = new Set([ properties[propName] ]);
}
}
// Ensure that "init" is defined by a parent-class or by the child:
if (!propsByName.hasOwnProperty('init'))
throw Error(`Class "${name}" is missing an "init" method`);
// For each property name in `propsByName`, ensure that there is no
// collision at that property name, and if there isn't, attach it to
// the prototype! `Object.defineProperty` can ensure that prototype
// properties won't appear during iteration with `in` keyword:
for (let propName in propsByName) {
let propsAtName = propsByName[propName];
if (propsAtName.size > 1)
throw new Error(`Class "${name}" has conflict at "${propName}"`);
Object.defineProperty(Class.prototype, propName, {
enumerable: false,
writable: true,
value: propsAtName.values().next().value // Get 1st item in Set
});
}
return Class;
};
let Named = makeClass('Named', {}, () => ({
init: function({ name }) {
this.name = name;
}
}));
let Running = makeClass('Running', { Named }, protos => ({
init: function({ name, numLegs }) {
protos.Named.init.call(this, { name });
this.numLegs = numLegs;
},
run: function() {
console.log(`${this.name} runs with ${this.numLegs} legs.`);
}
}));
let Flying = makeClass('Flying', { Named }, protos => ({
init: function({ name, numWings }) {
protos.Named.init.call(this, { name });
this.numWings = numWings;
},
fly: function( ){
console.log(`${this.name} flies away with ${this.numWings} wings!`);
}
}));
let RunningFlying = makeClass('RunningFlying', { Running, Flying }, protos => ({
init: function({ name, numLegs, numWings }) {
protos.Running.init.call(this, { name, numLegs });
protos.Flying.init.call(this, { name, numWings });
},
takeFlight: function() {
this.run();
this.fly();
}
}));
let human = new Running({ name: 'human', numLegs: 2 });
human.run();
let airplane = new Flying({ name: 'airplane', numWings: 2 });
airplane.fly();
let dragon = new RunningFlying({ name: 'dragon', numLegs: 4, numWings: 6 });
dragon.takeFlight();
Các makeClass
chức năng cũng hỗ trợ thuộc tính của lớp; chúng được xác định bằng tiền tố tên thuộc tính với $
ký hiệu (lưu ý rằng tên thuộc tính cuối cùng mà kết quả sẽ bị $
xóa). Với suy nghĩ này, chúng tôi có thể viết một chuyên ngànhDragon
lớp mô hình hóa "loại" của Rồng, trong đó danh sách các loại Rồng có sẵn được lưu trữ trên chính Lớp đó, trái ngược với các trường hợp:
let Dragon = makeClass('Dragon', { RunningFlying }, protos => ({
$types: {
wyvern: 'wyvern',
drake: 'drake',
hydra: 'hydra'
},
init: function({ name, numLegs, numWings, type }) {
protos.RunningFlying.init.call(this, { name, numLegs, numWings });
this.type = type;
},
description: function() {
return `A ${this.type}-type dragon with ${this.numLegs} legs and ${this.numWings} wings`;
}
}));
let dragon1 = new Dragon({ name: 'dragon1', numLegs: 2, numWings: 4, type: Dragon.types.drake });
let dragon2 = new Dragon({ name: 'dragon2', numLegs: 4, numWings: 2, type: Dragon.types.hydra });
Những thách thức của đa kế thừa
Bất cứ ai theo dõi mã makeClass
chặt chẽ sẽ lưu ý một hiện tượng không mong muốn khá quan trọng xảy ra âm thầm khi đoạn mã trên chạy: khởi tạo một RunningFlying
kết quả sẽ dẫn đến HAI cuộc gọi đến nhà Named
xây dựng!
Điều này là do biểu đồ thừa kế trông như thế này:
(^^ More Specialized ^^)
RunningFlying
/ \
/ \
Running Flying
\ /
\ /
Named
(vv More Abstract vv)
Khi có nhiều đường dẫn đến cùng một lớp cha trong biểu đồ thừa kế của lớp con , các cảnh báo của lớp con sẽ gọi hàm tạo của lớp cha mẹ đó nhiều lần.
Kết hợp điều này là không tầm thường. Chúng ta hãy xem xét một số ví dụ với các tên lớp đơn giản hóa. Chúng ta sẽ xem xét lớp A
, lớp cha mẹ trừu tượng nhất, các lớp B
và C
, cả hai đều kế thừa từ A
và lớp BC
kế thừa từ B
và C
(và do đó về mặt khái niệm là "thừa kế kép" từ A
):
let A = makeClass('A', {}, () => ({
init: function() {
console.log('Construct A');
}
}));
let B = makeClass('B', { A }, protos => ({
init: function() {
protos.A.init.call(this);
console.log('Construct B');
}
}));
let C = makeClass('C', { A }, protos => ({
init: function() {
protos.A.init.call(this);
console.log('Construct C');
}
}));
let BC = makeClass('BC', { B, C }, protos => ({
init: function() {
// Overall "Construct A" is logged twice:
protos.B.init.call(this); // -> console.log('Construct A'); console.log('Construct B');
protos.C.init.call(this); // -> console.log('Construct A'); console.log('Construct C');
console.log('Construct BC');
}
}));
Nếu chúng ta muốn ngăn chặn việc BC
gọi hai lầnA.prototype.init
chúng ta có thể cần phải từ bỏ kiểu gọi trực tiếp các hàm tạo. Chúng tôi sẽ cần một số mức độ gián tiếp để kiểm tra xem các cuộc gọi trùng lặp có xảy ra hay không và ngắn mạch trước khi chúng xảy ra.
Chúng ta có thể xem xét thay đổi các tham số được cung cấp cho hàm thuộc tính: bên cạnh protos
, Object
chứa dữ liệu thô mô tả các thuộc tính được kế thừa, chúng ta cũng có thể bao gồm một hàm tiện ích để gọi một phương thức cá thể theo cách mà các phương thức cha mẹ cũng được gọi, nhưng các cuộc gọi trùng lặp được phát hiện và ngăn chặn. Chúng ta hãy xem nơi chúng ta thiết lập các tham số cho propertiesFn
Function
:
let makeClass = (name, parents, propertiesFn) => {
/* ... a bunch of makeClass logic ... */
// Allows referencing inherited functions; e.g. `parProtos.ParentClass3.usefulMethod`
let parProtos = {};
/* ... collect all parent methods in `parProtos` ... */
// Utility functions for calling inherited methods:
let util = {};
util.invokeNoDuplicates = (instance, fnName, args, dups=new Set()) => {
// Invoke every parent method of name `fnName` first...
for (let parName of parProtos) {
if (parProtos[parName].hasOwnProperty(fnName)) {
// Our parent named `parName` defines the function named `fnName`
let fn = parProtos[parName][fnName];
// Check if this function has already been encountered.
// This solves our duplicate-invocation problem!!
if (dups.has(fn)) continue;
dups.add(fn);
// This is the first time this Function has been encountered.
// Call it on `instance`, with the desired args. Make sure we
// include `dups`, so that if the parent method invokes further
// inherited methods we don't lose track of what functions have
// have already been called.
fn.call(instance, ...args, dups);
}
}
};
// Now we can call `propertiesFn` with an additional `util` param:
// Resolve `properties` as the result of calling `propertiesFn`:
let properties = propertiesFn(parProtos, util, Class);
/* ... a bunch more makeClass logic ... */
};
Toàn bộ mục đích của thay đổi ở trên makeClass
là để chúng tôi có thêm một đối số được cung cấp cho chúng tôi propertiesFn
khi chúng tôi gọi makeClass
. Chúng ta cũng nên lưu ý rằng mọi hàm được định nghĩa trong bất kỳ lớp nào bây giờ có thể nhận được một tham số sau tất cả các hàm khác, được đặt tên dup
, đó là một Set
hàm chứa tất cả các hàm đã được gọi là kết quả của việc gọi phương thức được kế thừa:
let A = makeClass('A', {}, () => ({
init: function() {
console.log('Construct A');
}
}));
let B = makeClass('B', { A }, (protos, util) => ({
init: function(dups) {
util.invokeNoDuplicates(this, 'init', [ /* no args */ ], dups);
console.log('Construct B');
}
}));
let C = makeClass('C', { A }, (protos, util) => ({
init: function(dups) {
util.invokeNoDuplicates(this, 'init', [ /* no args */ ], dups);
console.log('Construct C');
}
}));
let BC = makeClass('BC', { B, C }, (protos, util) => ({
init: function(dups) {
util.invokeNoDuplicates(this, 'init', [ /* no args */ ], dups);
console.log('Construct BC');
}
}));
Phong cách mới này thực sự thành công trong việc đảm bảo "Construct A"
chỉ được ghi lại một lần khi một phiên bản BC
được khởi tạo. Nhưng có ba nhược điểm, thứ ba là rất nghiêm trọng :
- Mã này đã trở nên ít đọc và có thể duy trì. Rất nhiều sự phức tạp ẩn đằng sau
util.invokeNoDuplicates
chức năng, và suy nghĩ về cách phong cách này tránh được việc gọi đa năng là không trực quan và gây đau đầu. Chúng ta cũng có dups
tham số pesky đó, thực sự cần được xác định trên mỗi hàm duy nhất trong lớp . Ôi.
- Mã này chậm hơn - yêu cầu nhiều hơn một chút và tính toán để đạt được kết quả mong muốn với nhiều kế thừa. Thật không may, đây có thể là trường hợp với bất kỳ giải pháp nào cho vấn đề đa yêu cầu của chúng tôi.
- Đáng kể nhất, cấu trúc của các chức năng dựa trên sự kế thừa đã trở nên rất cứng nhắc . Nếu một lớp con
NiftyClass
ghi đè một hàm niftyFunction
và sử dụng util.invokeNoDuplicates(this, 'niftyFunction', ...)
để chạy nó mà không cần gọi trùng lặp, NiftyClass.prototype.niftyFunction
sẽ gọi hàm có tên niftyFunction
của mọi lớp cha định nghĩa nó, bỏ qua mọi giá trị trả về từ các lớp đó và cuối cùng thực hiện logic chuyên biệt của NiftyClass.prototype.niftyFunction
. Đây là cấu trúc duy nhất có thể . Nếu NiftyClass
kế thừa CoolClass
và GoodClass
và cả hai lớp cha mẹ này cung cấp các niftyFunction
định nghĩa của riêng chúng, NiftyClass.prototype.niftyFunction
sẽ không bao giờ (không có rủi ro nhiều lần gọi) có thể:
- A. Chạy logic chuyên biệt
NiftyClass
trước, sau đó là logic chuyên biệt của các lớp cha
- B. Chạy logic chuyên biệt
NiftyClass
tại bất kỳ điểm nào khác ngoài sau khi tất cả logic cha chuyên biệt đã hoàn thành
- C. Hành xử có điều kiện tùy thuộc vào giá trị trả về của logic chuyên biệt của cha mẹ
- D. Tránh chạy
niftyFunction
hoàn toàn chuyên biệt của một phụ huynh cụ thể
Tất nhiên, chúng ta có thể giải quyết từng vấn đề bằng chữ ở trên bằng cách xác định các hàm chuyên dụng trong util
:
- A. xác định
util.invokeNoDuplicatesSubClassLogicFirst(instance, fnName, ...)
- B. xác định
util.invokeNoDuplicatesSubClassAfterParent(parentName, instance, fnName, ...)
( parentName
Tên của cha mẹ có logic chuyên biệt sẽ được theo sau bởi logic chuyên biệt của lớp con)
- C. xác định
util.invokeNoDuplicatesCanShortCircuitOnParent(parentName, testFn, instance, fnName, ...)
(Trong trường hợp này testFn
sẽ nhận được kết quả của logic chuyên biệt cho cha mẹ được đặt tên parentName
và sẽ trả về một true/false
giá trị cho biết liệu có xảy ra ngắn mạch hay không)
- D. xác định
util.invokeNoDuplicatesBlackListedParents(blackList, instance, fnName, ...)
(Trong trường hợp này blackList
sẽ là một Array
tên cha mẹ có logic chuyên biệt nên được bỏ qua hoàn toàn)
Những giải pháp này đều có sẵn, nhưng đây là tình trạng hỗn loạn ! Đối với mọi cấu trúc duy nhất mà một lệnh gọi hàm được kế thừa có thể thực hiện, chúng ta sẽ cần một phương thức chuyên biệt được định nghĩa dưới đây util
. Thật là một thảm họa tuyệt đối.
Với suy nghĩ này, chúng ta có thể bắt đầu thấy những thách thức trong việc thực hiện nhiều kế thừa tốt. Việc thực hiện đầy đủ của makeClass
tôi được cung cấp trong câu trả lời này thậm chí không xem xét vấn đề đa yêu cầu, hoặc nhiều vấn đề khác phát sinh liên quan đến nhiều kế thừa.
Câu trả lời này đang trở nên rất dài. Tôi hy vọng việc makeClass
triển khai tôi đưa vào vẫn hữu ích, ngay cả khi nó không hoàn hảo. Tôi cũng hy vọng bất cứ ai quan tâm đến chủ đề này sẽ có được nhiều bối cảnh hơn để ghi nhớ khi họ đọc thêm!