Unpacking JavaScript 02: Object Oriented JS(OOJS) part 3 - 2D array Module
Introduction
this is a final article in the OOJS series, with the intention of solidifying the OOJS concepts covered already in part 1 and 2
Typed arrays
we will use typed arrays as a part of this module, I just wanted an excuse to introduce them, because I think they are pretty cool and awesome to know, in this case(2d array) we do not really care about performance or nothing that much, but buffers are very fast and are used by JS to store data such as audio , image data and so on, they are very useful..
typed arrays – allow access and manipulation of raw binary data. To start working with them, there are two concepts you need to get familiar with, Buffers and Views.
A buffer is a fixed chunk of memory (bytes), basically asking the computer to preserve a chunk of space, which can be accessed later.
Buffers cannot be accessed directly they are a representation of data, that is where views come in, views present an API to access and manipulate buffers, but most importantly views give buffers “shape”
For example, when you ask the computer for a buffer with length 16, the computer doesn’t know the type, number of elements and basically the shape or even the purpose of the data to be stored in that chunk.
Buffers preserve space and views turn the buffer(that space) into something useful and provide an API to interact with that data/space, Hopefully a little bit of code will give some clarity, but don’t fret typed arrays and buffers will keep coming up in the upcoming sections, giving you enough chance to get use to them.
// creating a 24 bytes buffer
let buffer = new ArrayBuffer(24) // space
console.log(buffer)
/*ArrayBuffer {
[Uint8Contents]: <00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00>,
byteLength: 24
}*/
Thinking about bytes: picture 10 contiguous squares like an array of sort, which are empty and each box can take one value of size 1, meaning if I have items of size 2, I can only fit 5 items, because each item will take two boxes and so on.
values are to buffers what these items are to our contiguous array(values with size), let’s say a single integer equals 4 bytes, to calculate how many ints we can fit in the above buffer of length 24, we just say 24/4 and we get the number of elements we can fit. So how do we know the length in bytes of an element?, conveniently enough through the view
view - Size in bytes
Int8Array - 1 // each element in this view takes 1 byte
Uint8Array - 1
…
Uint16Array - 2
Int32Array - 4
Float64Array - 8
// these are just few examples, you do not have to memorize them, you can just look them up
Let’s create a view for the buffer we defined above
let buffer = new ArrayBuffer(24)
let view = new Uint32Array(buffer, 0, 1) // each element equals 4 bytes
/*
The first param is the buffer we are viewing
The second is the offset(where do we want the view to start, 0 being the start, and 24 the end)
The last param is the length of the view, 1 means 'view' will take the first 4 spaces, note by take that does not mean you cannot create other views to “look” at that 'viewed' area, you can have multiple views viewing the same area, buffers are independent of views
*/
// for example
let buffer = new ArrayBuffer(24)
// each element will take 4 bytes (so we can only fit 6 elements)
let firstView = new Uint32Array(buffer, 0, 6) // 6 * 4(bytes) = 24
// each element will take 1 bytes hence we can fit 24 elements
let secondView = new Int8Array(buffer, 0, 24)
console.log(firstView)
console.log(secondView)
// the above is valid
I am assuming you know a little bit about bits, although not that necessary but very useful to know, I suggest looking them up,
for example unsigned 8 bit can only represent numbers from 0 - 255, as you go up in bits the more numbers you can represent or store.
All you need to know about type arrays before you work with them is the size of each element in the view, so you can scale it accordingly in the buffer, we will see an example of this as we implement a 2d array
Before we start there will be large amount of code from here on, so to avoid repeating the same code, I will use three dots to signal we are adding on the existing code.
2D array implementation
Our 2D array will have an option to create a numeric array only or a mixture of data types (strings, objects etc), for numbers we will use typed arrays
class twoDArray {
// to hold the created Array
Matrix = undefined;
constructor(rows, cols, type=Number, Matrix=null){
}
}
The constructor will run on class initialization, type will determine whether it’s a mixed or just numbers array, by default the type being Number, any value other than Number means it’s a mixed array, Matrix allows passing in an already created 2darray to be augmented.
. . .
constructor(rows, cols, type=Number, Matrix=null){
//assigning params to local variables
this.rows = rows;
this.cols = cols;
this.type = type;
// if an array is passed in
if(Array.isArray(Matrix)){
let newMap = new Map() // map(acts like an object)
for(let i = 0; i < Matrix.length; ++i){
newMap.set(i, Matrix[i])
}
this.Matrix = newMap;
}
}
In the if statement we are checking if a custom 2d Array was passed, if it was passed we create our new Matrix after the provided array, I am using the map data structure here, it is basically an object with a nicer API and fewer prototypical inheritance, plus it allows any values to be keys,
Map.set() takes in a key and value, lastly we are setting the public field Matrix to the newly created map, we can test this, just console the array after the last line,
// checkers board
let six = [ [' ', 'R', ' ', 'R', ' ', 'R'],
['R', ' ', 'R', ' ', 'R', ' '],
[' ', 'E', ' ', 'E', ' ', 'E'],
['E', ' ', 'E', ' ', 'E', ' '],
[' ', 'B', ' ', 'B', ' ', 'B'],
['B', ' ', 'B', ' ', 'B', ' '],]
//row col type custom array
let board = new twoDArray(6, 6, "any", six)
you can log the array in the constructor to see it
constructor(){
…
if(){
….
console.log(this.Matrix)
}
//results
/*
Map(6) {
0 => [ ' ', 'R', ' ', 'R', ' ', 'R' ],
1 => [ 'R', ' ', 'R', ' ', 'R', ' ' ],
2 => [ ' ', 'E', ' ', 'E', ' ', 'E' ],
3 => [ 'E', ' ', 'E', ' ', 'E', ' ' ],
4 => [ ' ', 'B', ' ', 'B', ' ', 'B' ],
5 => [ 'B', ' ', 'B', ' ', 'B', ' ' ]
}
*/
}
Excellent, now we can add custom functionality like look up, looping etc, but first we need to handle number array(type=Number and Matrix=null)
Under the if in the constructor, lets handle numbers only array
. . .
constructor(){
. . .
if(type === Number && Matrix === null){
this.bufferLen = (rows * cols) * 4; // each element will be four bytes long
this.buffer = new ArrayBuffer(this.bufferLen)
}
}
For bufferLen we are scaling the buffer so each element in the array will take 4 bytes, because we will use Int32Array as the view, the formula for scaling is easy (rows * cols) * byte size, then we create a buffer of that size, we will not initialize the array on the constructor but will provide an init method, so the developer can decide when to init the array(lazy initialization well sort of).
So far this is all the code we have :
class twoDArray {
Matrix = undefined;
constructor(rows, cols, type=Number, Matrix=null){
this.rows = rows;
this.cols = cols;
this.type = type;
if(Array.isArray(Matrix)){
let newMap = new Map()
for(let i = 0; i < Matrix.length; ++i){
newMap.set(i, Matrix[i])
}
this.Matrix = newMap;
console.log(this.Matrix)
}
if(type === Number && Matrix === null){
this.bufferLen = (rows * cols) * 4;
this.buffer = new ArrayBuffer(this.bufferLen)
}
}
}
let numbers = new twoDArray(6, 6) // omitting type and matrix to default vals Number and null
The above should create a numbers only array(or accurately a space in memory)
Initializing the array
Initializing an array is generic in our case, we do not need a unique init function for every instance, regardless of the type of array: mixed or number array, initializing is the same, so the init array method will be in the prototype(classes are functions underneath they have the prototype property) and every instance will inherit it, rather than creating a new method all the time, this is super-efficient.
Outside the class:
/**
*
* _@param_ {Any} fill - val to fill the array with, defaults to 0,
*/
twoDArray.prototype.initArray = function(fill = 0){
}
if you have no idea what the following does and is in JS, I have an article here explaining it
/**
*
* _@param_ {Any} fill - val to fill the array with, defaults to 0,
*/
The initArray method takes a fill value to populate the array with, if none is given the array will be initialized with 0s.
We only have two cases in this module, a type number array and not a number array, the only case we need to explicitly handle is type number(as we using Typed arrays) all others are the same.
. . .
twoDArray.prototype.initArray = function(fill = 0){
/**
* @type {Map}
*/
this.Matrix = new Map()
if(this.type === Number){
// if the fill value ain't a number
if(typeof fill !== "number"){
throw Error('cannot fill Int32Array w/' + typeof fill)
}
for(let i = 0; i < this.rows; ++i){
// explanation below
this.Matrix.set(i,new Int32Array(this.buffer, (i * this.cols)*4, this .cols).fill(fill))
}
}
}
One thing to note when defining a function in the property prototype use the function
keyword, arrow functions mess up the this
keyword, we want this to point to the current object/instance executing it, with arrows this is undefined,
another edge case in init, we create a new map and assign it to Matrix(overriding whatever is in this.Matrix), what if the developer already passed a custom array in the constructor, we can handle that case but let’s assume the consumers of the module know what they are doing.
Given type of number, we first check if the passed in fill is a number, if not we throw an error, the interesting part happens in the for loop, we are creating rows of Int32Array views of length column, buffer arrays are one dimensional, so we use the index to offset the row correctly and use a scalar 4, saying each element in the row takes 4 bytes, lets go over a simple example to make this clear create a new script:
const rows = 3;
const columns = 3;
//the buffer is (3 * 3) * the size of bytes for each element(determined by the view used)
const bufferLen = (rows * cols) * 4;
let buffer = new ArrayBuffer(bufferLen) // one dimensional array so we need to offset the view by column size,
for(let i = 0; i < rows; ++i){
new Int32Array(buffer, (i * cols)*4, cols)
// what happens here is: the first loop where I equals 0 (0 * cols)*4 = 0, the offset is zero, meaning this view starts at beginning of the buffer
// 2nd iter i = 1, (1 * cols)*4 = columns scaled four times, think of it as moving from the first occupied chunk of memory to the second available, the offset here is 12, because the first 12 columns are occupied by the first three elements of size 4, so 3 * 4 is 12
// last iteration, we need to move two times, because the first two chunks are occupied already,
// (2* cols) * 4 we find the next and last empty chunk and place the last row there
}
This is exactly what we are doing in the initArray method, and mapping those created views to indexes:
this.Matrix.set(i,new Int32Array(this.buffer, (i * this.cols)*4, this .cols).fill(fill))
For “normal” arrays, we use the same method(one D array) but store the values in a normal array, basically following the same method of mapping each row to an index, this makes a robust api in terms of accessing and manipulating the arrays regardless of their types(will become clear later)
twoDArray.prototype.initArray = function(fill = 0){
. . .
else {
for(let i = 0; i < this.rows; ++i){
this.Matrix.set(i,new Array(this.cols).fill(fill))
}
}
}
Now initing arrays should work,
let m = new twoDArray(6, 6) // 2d array of 6 * 6
m.initArray(0) // fill it with 0s
let p = new twoDArray(8, 8)
p.initArray(1)
console.log(m.Matrix)
console.log(p.Matrix)
Setting Values
A note on method overloading, method overloading is defining two or more methods of the same name, and execution is controlled or determined by the type of input param, however since JS does not have type checking we have to manually do it and decide which code inside a function must run given a type of param, a hello world of method overloading usually involves coordinates, because they can be represented in many forms, single digits x =0, y =0, as an object coord = {x: 0, y:0), we can certainly do this with a single function, but overloading helps the consumer of the api, usually with that pop up window which has 1/something in vscode, which tell developers how many forms this method can take,
setrowNcol = (coord) => {};
setrowNcol =(x, y = undefined, val = undefined) =>{
if(y){ // if y is not undefined coords are like this x =0, y =0
console.log(“passed multiple args”)
}
else{
console.log(“passed single arg”) // coord = {x: 0, y:0)
}
}
this.setrowNcol({x: 0, y: 0, val: 10}) // passed single arg
this.setrowNcol(0, 0, 10) // passed multiple args
/*
In an explicitly typed language, the above (of course with types defined) simply says if setrowNcol receives a single argument, execute the first function, else the second for multiple args,
In the second definition y and val are undefined, to account for the use of the first one, meaning if a person passes one argument, the others remained undefined as their default values if these are overridden it means multiple arguments have been passed,
*/
With that out of the way, we will implement the exact functions for the matrix, however we won’t define two functions, because they’ll just override each other, as they are in the prototype, we can simulate method overload with combination of JSDoc and little logic
/**
*
* @param {object|Number} x - {x: number, y: number, val:any} || x : number
* @param {Number} y
* @param {Any} val
*/
twoDArray.prototype.setrowNcol = function(x, y = undefined, val = undefined){
if(y){
this.Matrix.get(x)[y] = val
/*
First we get the x row from the map data structure(which returns an array)
Then we get the y column in the returned array and set it to value: val_
*/
} else{
this.Matrix.get(x.x)[x.y] = x.val
// same as above instead of single vals, we are using an object
// I store val along the x and y coordinates, which is actually wrong val is not a coordinate, but’s it’s quick you can separate them if you like
}
}
// using one of the 2d arrays declared above, we can now set values
// using either single variables or an object
m.setrowNcol({x: 0, y: 0, val: 20})
m.setrowNcol( 0, 1, 30)
console.log(m.Matrix)
/*
Map(6) {
0 => Int32Array(6) [ 20, 30, 0, 0, 0, 0 ],
…
}
*/
as simple as that we have a way to set values, the actual power of the Map api, now we need a get method
getting values
We invert setting values, there is nor much difference in the implementation, we take an object or values.
/**
*
* @param {Number|object} x - row
* @param {Number} y - column
*/
twoDArray.prototype.getrowNcol= function(x, y = undefined){
if(y){
return this.Matrix.get(x)[y]
}else{
return this.Matrix.get(x.x)[x.y]
}
console.log(m.getrowNcol({x:0, y:0})) // 20
console.log(m.getrowNcol(0, 1)) // 30
Getting a single row
/**
*
* @param {Number} rowNo
* @returns
*/
twoDArray.prototype.getRow = function(rowNo){
return this.Matrix.get(rowNo)
}
console.log(m.getRow(0))
Looping over the matrix
Map can return an iterable: which is a protocol defining how to for..of over a structure, we will look at iterators in detail in the coming articles
/**
* @callback cb
* @param {Any} index/key - returned key
* @param {Array<any>} row - returned row
*
*
*/
/**
*
* @param {cb} cb - callback
*/
twoDArray.prototype.loop = function(cb){
const iter = this.Matrix.entries() // the iterable
for(let [key, val] of iter){
cb(key, val) // calling a callback function on each row
}
}
// usage
m.loop((index, row)=> {
console.log(index, row)
})
Let’s allow fill after the array has been initialized
/**
*
* @description fill array with given value
*/
twoDArray.prototype.fill = function(val){
if(this.type === Number && typeof val !== "number") throw Error("cannot fill buffer array with non number vals")
this.loop((index, row)=> {
row.fill(val)
})
}
// just looping over the array and using native Array method to fill it with the given value
Lastly let’s implement copy, copying an object, and maps are based on objects. You cannot technically copy an object directly, cause a variable points to an object, rather than having the actual object, you’ll just copy the pointer, editing the copy will affect the original also, there are many solutions to this, but we will stick with JSON.stringify which performs deep copy.
// helper function turning an object to a map
twoDArray.prototype.ObjectToMap = function(obj){
let newMap = new Map()
// handle copying the buffer
if(this.type === Number){
let buffer = this.buffer.slice() // make a copy of the bufferArray
for(let i = 0; i < this.rows; ++i){ // create new views for the copied buffer
newMap.set(i,new Int32Array(buffer, (i * this.cols)*4, this.cols))
}
}else{ // for normal arrays
for(let key in obj){
newMap.set(parseInt(key), obj[key])
}
}
return newMap
}
// copying the matrix
//add the ff line in the constructor – to handle passed array which are not Array.isArray()
this.Matrix = Matrix // will be overriden if need be, will allow Map to be passed in as a Matrix
copying the matrix
/*
We cannot directly stringify the map, so we need to turn it to an object first, then stringify it, lastly turn it back to an object using the helper
*/
twoDArray.prototype.copy = function(){
let obj = Object.create(null) // create empty object
this.loop((key, val)=> {
obj[key] = Array.from(val) // copy the Matrix to obj object
})
let copied = JSON.stringify(obj) // deep copy(cutting ties with the Matrix)
let newMap = this.ObjectToMap(JSON.parse(copied)) // unstringify the copied object and a turn it to a map using the helper
return new twoDArray(this.row, this.cols, type=this.type, newMap) // return a new matrix
}
// Just like that we have a deep copy
Testing Deep Copy
let six = [ [' ', 'R', ' ', 'R', ' ', 'R'],
['R', ' ', 'R', ' ', 'R', ' '],
[' ', 'E', ' ', 'E', ' ', 'E'],
['E', ' ', 'E', ' ', 'E', ' '],
[' ', 'B', ' ', 'B', ' ', 'B'],
['B', ' ', 'B', ' ', 'B', ' '],]
let norm = new twoDArray(6, 6, type="any", six)
let norm2 = norm.copy()
norm2.setrowNcol({x: 0, y: 0, val: "hello"})
norm.setrowNcol(0, 2, "world")
norm2.setrowNcol(0, 6, 90)
console.log("norm 2", norm2.Matrix) // changes made here must not affect norm
console.log("norm", norm.Matrix) // changes made here must not affect norm2
// buffer test
let n = m.copy() // we defined m earlier
n.setrowNcol({x: 0, y: 0, val: 100})
console.log(m.Matrix)
console.log(n.Matrix)
That’s all for now, we will come back to this 2darray in the computational media section to visualize it, hopeful now you can see the pros and cons of classes, prototype inheritance etc, prototypes helped in keeping the class clean and avoid duplication of methods for all instances, by defining common instances in the prototype. If you like you can even divide the module into to files one for the class and the other for prototypes,
This was simple, we only had one class and no inheritance, the more complex classes become the harder they are to manage and extend, especially working with inheritance. That is where design patterns come in, design patterns are common solutions to common problems, there are many great books out there about design patterns and excellent blogs, but most of them are created for typed languages, dofactory.com has a dedicate series covering 23 designs patterns in JS which is a great start, just to wet your taste buds I will cover one design pattern from each category in the next article
Conclusion
If you want a programming buddy I will be happy to connect on twitter , or you or you know someone who is hiring for a front-end(react or ionic) developer or just a JS developer(modules, scripting etc) I am looking for a job or gig please contact me: mhlungusk@gmail.com, twitter will also do
Thank you for your time, enjoy your day or night. until next time
Wow Sfundo, this is a really comprehensive and comprehendible tutorial. It is the first time I ever fully understood the buffer/view concept of Javascript, which I never used myself before, but I stumbled upon it several times in code gists of other users.
Thank you!
Thank you Thomas that is so nice of you to say and very encouraging, I am glad you found it useful, thank you!