Intenzion
Intenzion

Reputation: 81

Refactor function with nested for loop - Javascript

I need to delete nested for loop in a function I created. My function receive an associative array and I return a new array based on certain properties in order to group messages to use later. For example, I have two schools, many students. So I group them based on gender and grade. I don't how to refactor this function because I don't know much about algorithms. It doesn't matter if my logic need be erased completely or need to be done again. I must delete the second for loop. Also, I can return either an common array, associative array or just object. I tried to replicate my function with the same logic but different data:

var studentsArray = new Array();

studentsArray["SCHOOL_1"] = [
    // girls
    {id: '1', school: 'SCHOOL_1', grade: 'A', message: 'Congratulations!', isMan: false},
    {id: '2', school: 'SCHOOL_1', grade: 'A', message: 'Good work!', isMan: false},
    {id: '3', school: 'SCHOOL_1', grade: 'A', message: 'Ok', isMan: false},
    // boys
    {id: '4', school: 'SCHOOL_1', grade: 'A', message: 'Congratulations!', isMan: true},
    {id: '5', school: 'SCHOOL_1', grade: 'B', message: 'Good work!', isMan: true},
    {id: '6', school: 'SCHOOL_1', grade: 'B', message: 'Good work!', isMan: true},
    {id: '7', school: 'SCHOOL_1', grade: 'A', message: 'Congratulations!', isMan: true},
    {id: '8', school: 'SCHOOL_1', grade: 'B', message: 'Good work!', isMan: true},

];
studentsArray["SCHOOL_2"] = [
    // girls
    {id: '9', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: false},
    {id: '10', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: false},
    {id: '11', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: false},
    {id: '12', school: 'SCHOOL_2', grade: 'B', message: 'Good work!', isMan: false},
    {id: '13', school: 'SCHOOL_2', grade: 'B', message: 'Nice!', isMan: false},
    // boys
    {id: '14', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: true},
    {id: '15', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: true},
    {id: '16', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: true},
    {id: '17', school: 'SCHOOL_2', grade: 'B', message: 'Congratulations!', isMan: true},
];

function GroupMessages(schools, gender) {
    // Initialize object to return
    var result = [];
    // First loop
    for (var school in schools) {
        // Group students by gender
        var girls = schools[school].filter(student => !student.isMan);
        var boys = schools[school].filter(student => student.isMan);

        // Flag to determine unique grade per gender
        var boysHaveUniqueGrade = boys.map(student => student.grade).filter((v, i, a) => a.indexOf(v) === i).length === 1;
        var girlsHaveUniqueGrade = girls.map(student => student.grade).filter((v, i, a) => a.indexOf(v) === i).length === 1;

        // If exists a single student per gender, return the same
        if (girls && girls.length === 1) result.push(girls[0]);
        if (boys && boys.length === 1) result.push(boys[0]); 
 
    
        //////////////////////////
        //    Group by grades   //
        /////////////////////////

        if (boys && boys.length > 1 && boysHaveUniqueGrade && gender === 'man') {
            // Combine messages
            let messages = boys.map(boy => boy.message);
            // First student is the reference
            let student = boys[0];
            // Join messages
            student.message = messages.join('|');
            // Update object to return
            result.push(student);
        }

        if (boys && boys.length > 1 && !boysHaveUniqueGrade && gender === 'man') {
            // Group messages by level (maybe I don't need GroupByProperty function neither)
            let studentsByGrade = GroupByProperty(boys, 'grade');
            // Second loop. I return a boys students based on 'grade' property. (I NEED TO DELETE THIS SECOND FOR LOOP)
            for (let grade in studentsByGrade) {
                // First student is the reference
                let student = studentsByGrade[grade][0];
                // Combine messages
                let messages = studentsByGrade[grade].map(student => student.message);
                // Join messages
                student.message = messages.join('|');
                // Update object to return
                result.push(student);
                // Code continue but I stop code here...
            }
        }

        if (girls && girls.length > 1 && girlsHaveUniqueGrade && gender !== 'man') {
            // Combine messages
            let messages = girls.map(girl => girl.message);
            // First student is the reference
            let student = girls[0];
            // Join messages
            student.message = messages.join('|');
            // Update object to return
            result.push(student);


        }

        if (girls && girls.length > 1 && !girlsHaveUniqueGrade && gender !== 'man') {
            // Group messages by level (maybe I don't need GroupByProperty function neither)
            let studentsByGrade = GroupByProperty(girls, 'grade');
            // Second loop. I return a girls students based on 'grade' property. (I NEED TO DELETE THIS SECOND FOR LOOP)
            for (let grade in studentsByGrade) {
                // First student is the reference
                let student = studentsByGrade[grade][0];
                // Combine messages
                let messages = studentsByGrade[grade].map(student => student.message);
                // Join messages
                student.message = messages.join('|');
                // Update object to return
                result.push(student);
                // Code continue but I stop code here...
            }
        }
    }

    return result;
}

function GroupByProperty(objectArray, property) {
    let result = objectArray.reduce((acc, obj) => {
       var key = obj[property];
       if (!acc[key]) acc[key] = [];
       acc[key].push(obj);
       return acc;
    }, {});

    return result;
}

GroupMessages(studentsArray, 'woman'); // any other gender works as 'man'

Upvotes: 1

Views: 482

Answers (3)

Scott Sauyet
Scott Sauyet

Reputation: 50787

This old question recently came to my attention. The answer from user Mulan is fantastic; I would recommend spending the time to understand it thoroughly.

But I want to offer a somewhat different approach. It similarly builds atop existing functions, but doesn't go as low-level as that approach.

First, here was my initial solution to the problem:

const call = (fn, ...args) => fn (...args)
const groupBy = (fn) => (xs) =>
  xs .reduce ((a, x) => call (key => ((a [key] = [... (a [key] || []), x]), a), fn (x)), {})

const groupMessages = (students, gender) => 
  Object .values (groupBy (x => `${x.school}|${x.grade}`) (Object .values (students) 
    .flat ()
    .filter (({isMan}) => isMan == (gender == 'man'))))
    .map ((students) => ({
      ... students [0],
      message: students .map (s => s.message) .join ('|')
    }))

const students = {SCHOOL_1: [/* girls */ {id: '1', school: 'SCHOOL_1', grade: 'A', message: 'Congratulations!', isMan: false}, {id: '2', school: 'SCHOOL_1', grade: 'A', message: 'Good work!', isMan: false}, {id: '3', school: 'SCHOOL_1', grade: 'A', message: 'Ok', isMan: false}, /* boys */ {id: '4', school: 'SCHOOL_1', grade: 'A', message: 'Congratulations!', isMan: true}, {id: '5', school: 'SCHOOL_1', grade: 'B', message: 'Good work!', isMan: true}, {id: '6', school: 'SCHOOL_1', grade: 'B', message: 'Good work!', isMan: true}, {id: '7', school: 'SCHOOL_1', grade: 'A', message: 'Congratulations!', isMan: true}, {id: '8', school: 'SCHOOL_1', grade: 'B', message: 'Good work!', isMan: true}], SCHOOL_2: [/* girls */ {id: '9', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: false}, {id: '10', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: false}, {id: '11', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: false}, {id: '12', school: 'SCHOOL_2', grade: 'B', message: 'Good work!', isMan: false}, {id: '13', school: 'SCHOOL_2', grade: 'B', message: 'Nice!', isMan: false}, /* boys */ {id: '14', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: true}, {id: '15', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: true}, {id: '16', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: true}, {id: '17', school: 'SCHOOL_2', grade: 'B', message: 'Congratulations!', isMan: true}]}
         
console .log (groupMessages (students, 'woman'))
.as-console-wrapper {max-height: 100% !important; top: 0}

It uses a groupBy function that I keep around and use a lot. That in turn depends upon call, which accepts a function and a list of parameters and calls that function with those parameters. (I use it here simply to keep local variables to to a minimum.)

This works, and is clearly much shorter than the original code. But it has one real ugliness to it, common to a lot of such JS code. It's compact, but the order of operations is hard to see. It takes a deep understanding of the code to see this:

    const groupMessages = (students, gender) => 
      Object .values (groupBy (x => `${x.school}|${x.grade}`) (Object .values (students)
        /* ^-- 5 */   /* ^-- 4 */                                 /* ^-- 1 */ 
        .flat ()  /* <-- 2 */
        .filter (({isMan}) => isMan == (gender == 'man'))))  /* <-- 3 */
        .map ((students) => ({  /* <-- 6 */
          ... students [0],
          message: students .map (s => s.message) .join ('|')
        }))

I tend to use a pipe function which passes the result of one function to the next one and the result of that one to the following one, and so on. This cleans things up a lot. But it does require having curried functions that reify things like array methods.

So, I find this breakdown much cleaner:

// reusable utility functions
const pipe = (...fns) => (arg) => fns .reduce ((a, f) => f (a), arg)
const map = (fn) => (xs) => xs .map (x => fn (x))
const filter = (fn) => (xs) => xs .filter (x => fn (x))
const call = (fn, ...args) => fn (...args)
const groupBy = (fn) => (xs) =>
  xs .reduce ((a, x) => call (key => ((a [key] = [... (a [key] || []), x]), a), fn (x)), {})
const flat = (xs) => xs .flat ()

// helper function
const groupStudentMessages = (students) => ({
  ... students [0],
  message: students .map (s => s.message) .join ('|')
})

// main function, as a pipeline
const groupMessages = (students, gender) => pipe (
  Object .values,                                     /* <-- 1 */
  flat,                                               /* <-- 2 */ 
  filter (({isMan}) => isMan == (gender == 'man')),   /* <-- 3 */
  groupBy (x => `${x.school}|${x.grade}`),            /* <-- 4 */
  Object .values,                                     /* <-- 5 */
  map (groupStudentMessages)                          /* <-- 6 */
) (students)

const students = {SCHOOL_1: [/* girls */ {id: '1', school: 'SCHOOL_1', grade: 'A', message: 'Congratulations!', isMan: false}, {id: '2', school: 'SCHOOL_1', grade: 'A', message: 'Good work!', isMan: false}, {id: '3', school: 'SCHOOL_1', grade: 'A', message: 'Ok', isMan: false}, /* boys */ {id: '4', school: 'SCHOOL_1', grade: 'A', message: 'Congratulations!', isMan: true}, {id: '5', school: 'SCHOOL_1', grade: 'B', message: 'Good work!', isMan: true}, {id: '6', school: 'SCHOOL_1', grade: 'B', message: 'Good work!', isMan: true}, {id: '7', school: 'SCHOOL_1', grade: 'A', message: 'Congratulations!', isMan: true}, {id: '8', school: 'SCHOOL_1', grade: 'B', message: 'Good work!', isMan: true}], SCHOOL_2: [/* girls */ {id: '9', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: false}, {id: '10', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: false}, {id: '11', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: false}, {id: '12', school: 'SCHOOL_2', grade: 'B', message: 'Good work!', isMan: false}, {id: '13', school: 'SCHOOL_2', grade: 'B', message: 'Nice!', isMan: false}, /* boys */ {id: '14', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: true}, {id: '15', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: true}, {id: '16', school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: true}, {id: '17', school: 'SCHOOL_2', grade: 'B', message: 'Congratulations!', isMan: true}]}

console .log (groupMessages (students, 'woman'))
.as-console-wrapper {max-height: 100% !important; top: 0}

I hope that the order of operations in the main function is clear enough even without the annotations. To make that even more clear, we pull out the groupStudentMessages helper function, and make each pipeline step a one-liner.

Note that those first six functions are extremely helpful, reusable functions. All have counterparts in Ramda, of which I'm a founder. But these implementations are simple and are easy to move into any codebase.

Upvotes: 1

Mulan
Mulan

Reputation: 135197

reusable modules

This is great opportunity to learn about reusable modules. Your GroupMessages function is over almost 100 lines and it is tightly coupled with your data structure. The solution in this answer solves your specific problem without any modification of the modules written at an earlier time.

I will offer some code review at end of this answer, but for now we rename schools to grades becauase each item in the array represents a single grade of a single student at a particular school -

const grades =
  [ {id: 1, school: 'SCHOOL_1', grade: 'A', message: 'Congratulations!', isMan: false}
  , {id: 2, school: 'SCHOOL_1', grade: 'A', message: 'Good work!', isMan: false}
  , {id: 3, school: 'SCHOOL_1', grade: 'A', message: 'Ok', isMan: false}
  , {id: 4, school: 'SCHOOL_1', grade: 'A', message: 'Congratulations!', isMan: true}
  , {id: 5, school: 'SCHOOL_1', grade: 'B', message: 'Good work!', isMan: true}
  , {id: 6, school: 'SCHOOL_1', grade: 'B', message: 'Good work!', isMan: true}
  , {id: 7, school: 'SCHOOL_1', grade: 'A', message: 'Congratulations!', isMan: true}
  , {id: 8, school: 'SCHOOL_1', grade: 'B', message: 'Good work!', isMan: true}
  , {id: 9, school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: false}
  , {id: 10, school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: false}
  , {id: 11, school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: false}
  , {id: 12, school: 'SCHOOL_2', grade: 'B', message: 'Good work!', isMan: false}
  , {id: 13, school: 'SCHOOL_2', grade: 'B', message: 'Nice!', isMan: false}
  , {id: 14, school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: true}
  , {id: 15, school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: true}
  , {id: 16, school: 'SCHOOL_2', grade: 'A', message: 'Congratulations!', isMan: true}
  , {id: 17, school: 'SCHOOL_2', grade: 'B', message: 'Congratulations!', isMan: true}
  ]

As you learned, JavaScript does not have associative arrays. Nor does it have any native data structures which support lookups using compound keys (selecting a value with more than one key). We're going to import some functions from our binary tree module, btree, create an identifier for your records, myIdentifier, and use it to initialise your tree, myTree -

import { nil, fromArray, inorder } from "./btree.js"

const myIdentifier = record =>
  [ record?.school ?? "noschool" // if school property is blank, group by "noschool"
  , record?.grade ?? "NA"        // if grade property is blank, group by "NA"
  , record?.isMan ?? false       // if isMan property is blank, group by false
  ]

const myTree =
  nil(myIdentifier)

The binary tree automatically handles grouping based on the customisable identifier, and any number of grouping keys can be used. We will use a basic filter to select all grades matching the queried gender. The selected grades array is passed to fromArray along with a merging function that handles tree updates. inorder is used to extract the grouped values from the tree -

function groupMessages (grades, gender)
{ const t =
    fromArray
      ( myTree
      , grades.filter(x => !x.isMan || gender === "man")
      , ({ messages = [] } = {}, { message = "", ...r }) =>
          ({ ...r, messages: [ ...messages, message ]})
      )
  return Array.from(inorder(t))
}

Let's see the output now -

console.log(groupMessages(grades, 'woman'))
[
  {
    "id": "3",
    "school": "SCHOOL_1",
    "grade": "A",
    "isMan": false,
    "messages": [
      "Congratulations!",
      "Good work!",
      "Ok"
    ]
  },
  {
    "id": "11",
    "school": "SCHOOL_2",
    "grade": "A",
    "isMan": false,
    "messages": [
      "Congratulations!",
      "Congratulations!",
      "Congratulations!"
    ]
  },
  {
    "id": "13",
    "school": "SCHOOL_2",
    "grade": "B",
    "isMan": false,
    "messages": [
      "Good work!",
      "Nice!"
    ]
  }
]

To complete this post, we will show the implementations of btree,

// btree.js

import { memo } from "./func.js"
import * as ordered from "./ordered.js"

const nil =
  memo
    ( compare =>
        ({ nil, compare, cons:btree(compare) })
    )

const btree =
  memo
    ( compare =>
        (value, left = nil(compare), right = nil(compare)) =>
          ({ btree, compare, cons:btree(compare), value, left, right })
    )

const isNil = t =>
  t === nil(t.compare)

const compare = (t, q) =>
  ordered.all
    ( Array.from(t.compare(q))
    , Array.from(t.compare(t.value))
    )

function get (t, q)
{ if (isNil(t))
    return undefined
  else switch (compare(t, q))
  { case ordered.lt:
      return get(t.left, q)
    case ordered.gt:
      return get(t.right, q)
    case ordered.eq:
      return t.value
  }
}

function update (t, q, f)
{ if (isNil(t))
    return t.cons(f(undefined))
  else switch (compare(t, q))
  { case ordered.lt:
      return t.cons(t.value, update(t.left, q, f), t.right)
    case ordered.gt:
      return t.cons(t.value, t.left, update(t.right, q, f))
    case ordered.eq:
      return t.cons(f(t.value), t.left, t.right)
  }
}

const insert = (t, q) =>
  update(t, q, _ => q)

const fromArray = (t, a, merge) =>
  a.reduce
    ( (r, v) =>
       update
          ( r
          , v
          , _ => merge ? merge(_, v) : v
          )
    , t
    )

function* inorder (t)
{ if (isNil(t)) return
  yield* inorder(t.left)
  yield t.value
  yield* inorder(t.right)
}

export { btree, fromArray, get, inorder, insert, isNil, nil, update }

Reusability is essential. Modules can import other modules! Above, btree imports from func and ordered — partial modules included below -

// func.js

function memo (f)
{ const r = new Map
  return x =>
    r.has(x)
      ? r.get(x)
      : (r.set(x, f(x)), r.get(x))
}

export { memo }
// ordered.js

const lt =
  -1

const gt =
  1

const eq =
  0

const empty =
  eq

const compare = (a, b) =>
  a < b
    ? lt
: a > b
    ? gt
: eq

const all = (a = [], b = []) =>
  a.reduce
    ( (r, e, i) =>
        concat(r, compare(e, b[i]))
    , eq
    )

const concat = (a, b) =>
  a === eq ? b : a

export { all, compare, concat, empty, eq, gt, lt }

Upvotes: 3

btilly
btilly

Reputation: 46399

It seems odd to me that you have to delete the second for loop.

But still, this is the kind of problem that relational databases are meant to solve. If no other option exists, https://github.com/sql-js/sql.js is SQLite compiled to JavaScript.

Upvotes: -1

Related Questions