تسریع پردازش آرایه JavaScript

ترجمه و تالیف : عرفان کاکایی
تاریخ انتشار : 13 خرداد 98
خواندن در 7 دقیقه
دسته بندی ها : جاوا اسکریپت

وقتی که فقط تعداد کمی آیتم دارید، آرایه‌های JavaScript عالی هستند، اما وقتی که حجم زیادی از داده‌ها را دارید یا می‌خواهید تغییر شکل‌های (transformation) پیچیده‌ای را با استفاده از تعداد زیادی فراخوانی‌های متد map، filter و reduce انجام دهید، یک کاهش سرعت قابل توجه در کارایی، به هنگام استفاده از متدهای Array.prototype خواهید دید.

تسریع پردازش آرایه JavaScript

این مثال map ساده را در نظر بگیرید:

initialArray
.map(String)

حتی چیزی به سادگی یک map آرایه تکی، می‌تواند با استفاده از transducerها بسیار سریع‌تر شود. این افزایش سرعت را در بخش تجزیه و تحلیل کارایی خواهید دید، که شاید برای شما شگفت‌آور باشد.

مشکل

شاید تا زمانی که یک روند را سریع‌تر نکنید، حتی درک نکنید که چقدر کند است، اما همچنین موقعیت‌های نادری هم وجود دارند که یا با آرایه‌های بزرگی از داده‌ها، یا با مدیریت مقدار زیادی تغییر شکل کار خواهید کرد و نیاز به ارتقای کارایی خواهید داشت.

بیایید یک آزمایش کارایی با ۱۰ میلیون آیتم را با استفاده از Array.prototype.map انجام دهیم:

const timerName = 'Array Map'

const initialArray = (

  Array(10000000)

  .fill(null)

)

console

.time(timerName)

initialArray

.map(String)

console

.timeEnd(timerName)

و سپس بیایید آن را با حلقه بومی for مقایسه کنید:

const timerName = 'For Loop'

const initialArray = (

  Array(10000000)

  .fill(null)

)

console

.time(timerName)

const newArray = []

for (let i = 0, l = initialArray.length; i < l; i++) {

  newArray[i] = String(initialArray[i])

}

console

.timeEnd(timerName)

من هر کدام را ۷ بار اجرا کردم و از مدت زمان اجرای آن‌ها میانگین گرفتم. بر روی CPU من که مدل Intel Core i7-4770K می‌باشد، متد آرایه ما میانگین ۱۲۸۱ میلی ثانیه را پس داد، در حالیکه حلقه for ما میانگین ۳۲۳ میلی ثانیه را پس داد. شگفت انگیز است، اما حلقه‌های for متعلق به ۱۰ سال پیش هستند. نوشتن آن‌ها سخت است و انجام تغییر شکل‌های پیچیده هم حتی سخت‌تر است.

ما چگونه می‌توانیم این کارایی ۳۰۰ درصدی را از دست نداده، اما همچنان کد خود را خوانا نگه داریم؟

Transducerها به نجات ما می‌آیند

Transduce کردن، راهی است برای پردازش مقداری منطق بر روی هر آیتم تکی در آرایه، از طریق جمع‌بندی آن به یک مجموعه برگشتی از توابع کاهش دهنده، قبل از خروجی دادن یک مقدار. این مسئله نسبت به متدهای آرایه JavaScript متفاوت است؛ زیرا هر آیتم در آرایه قبل از ادامه دادن به مورد بعدی، با یک متد map، filter یا reduce پردازش می‌شود، و هر قدم از راه آرایه‌های جدیدی را برای ذخیره سازی داده‌ها می‌سازد.

کتابخانه‌های زیادی برای مدیریت transduce کردن‌ها، و چندین مقاله برای نحوه جایگزینی متدهای آرایه بومی وجود دارند، اما من می‌خواهم یکی از آزمایشاتی که در یکی از پروژه‌های خانگی خود اجرا کردم را بررسی کنم، که این آزمایش استفاده از RxJS به جای متدهای آرایه بومی JavaScript بود.

من باور دارم که یادگیری یک چیز به صورت خوب، از این که مجبور باشید چندین چیز دیگر یاد بگیرید بسیار ساده‌تر و ارزان‌تر است. با این که استفاده از ابزار مناسب برای کار خود مهم است، من همیشه در حال بررسی راه‌هایی برای استفاده از ابزار مشابه برای چند کار دیگر هستم؛ زیرا این کار می‌تواند هزینه یادگیری گروه را کاهش دهد و به طرز قابل توجهی هزینه‌های پیاده‌سازی یک پروژه را کاهش دهد.

این پیاده‌سازی، آزمایشی است. به طور معمول من مقاله‌هایی درباره چیزهایی که به تولید رسانده‌ام می‌‌نویسم، اما این مورد درباره ایده‌هایی که به آن‌ها رسیده‌ام تا قابلیت‌های RxJS را گسترش دهم می‌باشد.

Transducerهای RxJS

یک transducer، یک آیتم تکی را درست به مانند یک جریان (stream) RxJS از چندین تابع می‌گذراند، و این به خصوص حال که RxJS الگوی transducer را با متد pipe و عملیات‌های امکان پذیر پیاده‌سازی می‌کند، مناسب است؛ زیرا ممکن است راهی برای سازگارسازی این موارد وجود داشته باشد.

RxJS می‌تواند برای transducerهای همگام به جای متدهای آرایه مناسب باشد، اما چند هشدار هم هستند که باید به شما بدهم.

در ابتدا، به هر دلیلی که شده RxJS رابط transducer را به مانند کتابخانه‌های transducer دیگر پیاده‌سازی نمی‌کند، پس به صورت پیشفرض با آن‌ها سازگاری ندارد. اگر فقط از RxJS استفاده می‌کنید، این مسئله مهمی نیست.

دوما این که RxJS به متد subscribe (اشتراک) تکیه می‌کند، که این متد از یک callback استفاده می‌کند. متدهای آرایه، آرایه‌ها را بر می‌گردانند، نه موارد observable (قابل مشاهده) که شامل مقادیر یک آرایه هستند. پس چه کاری باید انجام دهیم؟ اگر از BehaviorSubject استفاده نمی‌کنید، نمی‌توانید یک مقدار همگام را بگیرید و حتی در هنگام استفاده از آن، نمی‌توانید مطمئن باشید که مقداری که پس می‌گیرید، چیزی است که انتظارش را دارید.

به علاوه، موارد قابل مشاهده RxJS هیچ فرقی بین عملگرهای ناهمگام و همگام قرار نمی‌دهد. شما می‌توانید یک wrapper همگام به دور منطق خود قرار دهید، اما این کار فقط در حین رانش (runtime) از شما محافظت خواهد کرد.

مقداری آزمایش

از آنجایی که BehaviorSubject در RxJS به چیزی که ما می‌خواهیم نزدیک است، بیایید به یک پیاده‌سازی با استفاده از آن نگاه داشته باشیم:

const { BehaviorSubject } = require('rxjs')

const { map, mergeMap, toArray } = require('rxjs/operators')

const behaviorSubject = (

  new BehaviorSubject(

    [1, 2, 3, 4]

  )

)

behaviorSubject

.pipe(

  mergeMap(array => (

    array

  )),

  map(String),

  toArray(),

)

.subscribe()

.unsubscribe()

const { value } = behaviorSubject

console.log(value)

// [1, 2, 3, 4] -- BROKEN!

این کاری نیست که بخواهیم در مرکز کد خود به طور مداوم انجام دهیم. پس آیا باید بعد از اشتراک (subscribe) به یاد داشته باشیم که لغو اشتراک (unsubscribe) کنیم؟ و آیا باید هر بار mergeMap را به یاد داشته باشیم؟ عمرا!

همچنین دقت کنید که این حتی کار هم نمی‌کند. مقدار شما، آرایه‌ اصلی که شما منتقل کردید است، نه آرایه‌ای که از لوله‌کشی‌ها (pipeline) گذشت. شما مقدار transduce شده خود را فقط می‌توانید با منتقل کردن یک handler به تابع subscribe بردارید؛ پس به نظر می‌رسد که BehaviorSubject بسیار بی ارزش است.

اگر ما از from استفاده کنیم، می‌توانیم همین رفتار را تقلید کنیم، آن را به کار بیندازیم و از کد کمتری هم استفاده کنیم:

const { from } = require('rxjs')

const { map, toArray } = require('rxjs/operators')

let value

from([1, 2, 3, 4])

.pipe(

  map(String),

  toArray(),

)

.subscribe(modifiedArray => {

  value = modifiedArray

})

.unsubscribe()

console.log(value)

// ['1', '2', '3', '4']

این کد به شدت مرتب‌تر است و حال مقدار ما صحیح است، اما همچننان مشکلات مشابه قبل را دارد: نگهداری و نوشتن آن سخت است. اگر فقط یک نقطه در مرکز کد شماست، مشکلی ندارد، اما اگر بیشتر از آن باشد چه؟ به فکر راه‌های بهتر باشید.

راه حل

چیزی که ما به آن نیاز داریم، راهی برای حذف وابستگی ما به متد subscribe برای ساخت یک observable به صورت همگام و برگرداندن یک مقدار است. در این مورد، ما یک آرایه می‌خواهیم.

برای به دست آوردن یک مقدار، به چنین چیزی در observable خود نیاز خواهیم داشت:

source$
.execute = () => {
  // انجام منطق مربوطه
 
  return value
}

با این که این متد در حال حاضر در RxJS وجود ندارد، اما قطعا چیزی است که می‌توانیم خودمان در پروژه‌های خود اضافه کنیم.

RxJS یک متد، مشابه به متد دست ساخت ما یعنی execute، به نام forEach دارد، اما این متد مشابه Array.prototype.forEach ساخته شده است، با این تفاوت که به جای برگرداندن undefined، این متد یک promise‌ را بر می‌گرداند. متد then در این promise هیچ چیز را بر نمی‌گرداند، اما وقتی که تابع observable شما به اتمام رسیده باشد، به شما اطلاع می‌دهد. اگر این متد با یک خطا رو به رو شود، catch فعال خواهد شد. این یک راه‌اندازی بسیار عالی است، اما وقتی که در حال تلاش برای تکثیر عملیات‌های همگام آرایه هستیم، خیلی کاربردی نیست.

از آنجایی که ما در حال سر و کله زدن با آرایه‌ها هستیم، در طی گذر زمان هیچ مقادیری وجود ندارند. RxJS در حال حاضر نیز این مسئله را مدیریت می‌کند و وقتی که به انتهای لیست می‌رسد، observableها را به اتمام می‌رساند. اگر ما subscribe را در تابع execute خود جمع‌بندی کنیم، می‌توانیم آن را مجبور کنیم که یک مقدار همگام را مشابه به نحوه کار async-await برگرداند، بدون این که نیاز باشد کل برنامه خود را بازنویسی کنید.

پیاده‌سازی

من تصور می‌کنم که به این صورت از execute استفاده خواهیم کرد:

const { from } = require('rxjs')

const { map, toArray } = require('rxjs/operators')

const createExecutableObservable = require('./createExecutableObservable')

const value = (

  createExecutableObservable(

    from([1, 2, 3, 4])

  )

  .pipe(

    map(String),

    toArray(),

  )

  .execute()

)

console.log(value)

// ['1', '2', '3', '4']

و با این طرز پیاده‌سازی خاص، من توانستم وقتی که کد ناهمگام اجرا می‌شد، یک خطا را بگیرم:

const { delay, map, toArray } = require('rxjs/operators')

const { from } = require('rxjs')

const createExecutableObservable = require('./createExecutableObservable')

const asyncValue = (

  createExecutableObservable(

    from([1, 2, 3, 4])

  )

  .pipe(

    map(String),

    delay(1000),

    toArray(),

  )

  .execute()

)

console.log(asyncValue)

// undefined

// 1 second later

// Error: Cannot return multiple values in `createExecutableObservable`. Make sure your pipeline returns only a single value and doesn't use asynchronous operators.

به undefined بودن مقدارمان دقت کنید. اما ما همچنان می‌توانیم در زمانی دیگر، یک خطا درباره استفاده از آن ببینیم. از آنجایی که این کد ناهمگام است، ما نمی‌توانیم از try-catch برای یافتن خطا استفاده کنیم، اما می‌توانیم آن را در حین رانش در کنسول لاگ کنیم، تا اگر مشکلی پیش آمد بتوانیم از آن آگاه باشیم.

const { Observable } = require('rxjs')

class ExecutableObservable extends Observable {

  execute(onError) {

    let hasExecuted = false

    let returnValue

    const next = (

      value,

    ) => {

      if (hasExecuted) {

        console

        .error(

          new Error(

            "Cannot return multiple values in `createExecutableObservable`. Make sure your pipeline returns only a single value and doesn't use asynchronous operators."

          ),

        )

      }

      returnValue = value

    }

    this

    .subscribe({

      complete: () => {},

      error: onError,

      next,

    })

    hasExecuted = true

    return returnValue

  }

  lift(operator) {

    const observable = new ExecutableObservable()

    observable

    .source = this

    observable

    .operator = operator

    return observable

  }

}

const createExecutableObservable = (

  source$,

) => {

  source$

  .__proto__ = new ExecutableObservable()

  return source$

}

module.exports = createExecutableObservable

اما این پیاده‌سازی یک ایراد دارد، آن ایراد این است که در آن شما می‌توانستید یک observable ناهمگام داشته باشید، که هیچ وقت لغو اشتراک نمی‌کند و فقط خطاها را لاگ می‌کند.

در عوض باید سریعا پس از اجرا، لغو اشتراک کنیم، اما به این صورت هیچ راهی نخواهیم داشت که بدانیم آیا observable ما عملگرهای دیگری دارد یا نه:

const { Observable } = require('rxjs')

class ExecutableObservable extends Observable {

  execute(onError) {

    let returnValue

    const next = (

      value,

    ) => {

      returnValue = value

    }

    this

    .subscribe({

      complete: () => {},

      error: onError,

      next,

    })

    .unsubscribe()

    return returnValue

  }

  lift(operator) {

    const observable = new ExecutableObservable()

    observable

    .source = this

    observable

    .operator = operator

    return observable

  }

}

const createExecutableObservable = (

  source$,

) => {

  source$

  .__proto__ = new ExecutableObservable()

  return source$

}

module.exports = createExecutableObservable

تنها راه، اضافه کردن یک عملگر take(1) و لغو اشتراک درست پس از این که یک خطا را نمایش دادید می‌باشد، اما از آنجایی که به این روش و با داشتن یک observable که هیچگاه اجرا نمی‌شوند، نمی‌توانید درباره نشت‌های حافظه مطمئن بشید، پس این مورد هم احتمالا پیاده‌سازی خوبی نیست.

من فکر می‌کنم که لغو اشتراک خودکار، بیشتر چیزی است که انتظار دارید. به این صورت، شما هیچ وقت هیچ یک نشتی حافظه در اثر استفاده از execute نخواهید داشت؛ حتی با این که برخی خطاهای کاربردی را از دست خواهید داد.

اگر من از ترکیب تابعی استفاده کرده بودم و به جای گسترش کلاس، خودم در observable مورد نظر مشترک می‌شدم، کد من بسیار مرتب‌تر می‌‌بود؛ اما می‌خواستم ببینم که اگر این روند در RxJS پیاده‌سازی شود، چگونه خواهد بود.

این API می‌توانست با استفاده از from بیشتر ارتقا یابد. این کار یعنی یک چیز کمتر برای وارد کردن، در هنگام هضم createExecutableObservable.

const createExecutableObservable = (

  source$,

) => {

  Array

  .isArray(source$)

  && (

    source$ = from(source$)

  )

  source$

  .__proto__ = new ExecutableObservable()

  return source$

}

تاثیر بر روی کارایی

حال که منطق transducer خود را ساخته‌ایم، بیایید یک آزمایش کارایی با استفاده از مقایسه مشابهی که پیش‌تر داشتیم، اجرا کنیم:

const { from } = require('rxjs')

const { map, toArray } = require('rxjs/operators')

const createExecutableObservable = require('./createExecutableObservable')

const timerName = 'Transducer'

const initialArray = (

  Array(10000000)

  .fill(null)

)

console

.time(timerName)

createExecutableObservable(

  from(initialArray)

)

.pipe(

  map(String),

  toArray(),

)

.execute()

console

.timeEnd(timerName)

API مورد نظر بر روی حلقه for بسیار مرتب‌تر است، اما نسبت به آزمایش map کد بسیار بیشتری دارد، پس همانطور که انتظار می‌رود این کار کمی زمان بیشتری خواهد برد. منظورم این است که مثال بومی در مقایسه با این هیولا، فقط یک فراخوانی متد تکی است.

پس از این که من آزمایش کارایی را اجرا کردم، با دیدن میانگین ۳۷۱ میلی ثانیه شگفت‌زده شدم. این تقریبا ۲۴۰ درصد سریع‌تر از آزمایش map ما، و فقط ۱۳ درصد کندتر از حلقه for ما است. به نظر من این برایم کافی است، اما اگر بر روی سیستم‌های جاسازی شده کار می‌کنید و محدودیت‌های پردازش بسیار تنگی دارید، احتمالا به اندازه کافی خوب نیست.

در جهت داشتن یک خوانایی بهتر، آیا شما ترجیح خواهید داد که از یک حلقه for استفاده کنید، یا این که از دانش فعلی خود درباره transducerهای RxJS استفاده کنید؟ نگهداری کدام یک ساده‌تر خواهد بود؟

داستان واقعی

Transducerها آنچنان ساده نیستند. اگر تعداد بسیار کمی از آیتم‌ها داشته باشید، transducerها حتی از راه حل‌های دیگر هم کندتر خواهند بود. این که بخواهیم روش متعادل نهایی را برای اکثر ماشین‌ها بیابیم، خارج از محدوده این مقاله است، اما بیایید این آزمایش‌های کارایی را با آرایه‌ای متشکل از ۳ آیتم، مجددا انجام دهیم:

  • متد آرایه: ۰.۳۳ میلی ثانیه
  • حلقه for: ۰.۲۲ میلی ثانیه
  • Transducer: ۳.۰۶ میلی ثانیه

به طور کاملا ناگهانی، نتایج برعکس شده‌اند. ۳ میلی ثانیه برای سه آیتم؟ این مقدار از دو راه حل دیگر هم طولانی‌تر است.

آن همه منطق transducer، هزینه‌ای را هم به همراه داشت. راه‌اندازی آن با این که در عملیات‌های بزرگ سریع می‌باشد، اما در هنگام کار با مجموعه‌های کوچک داده کند است.

در اینجاست که مسئله «از ابزار مناسب برای کار مناسب استفاده کنید» به میان می‌آید. اگر یک آرایه کوچک دارید، از متدهای داخلی استفاده کنید. به این صورت شما منفعت آن عملگرهای شگفت‌انگیز RxJS مانند tap را از دست خواهید داد، اما مقدار خوبی سرعت را به دست خواهید آورد، و مجبور نخواهید بود که متدهای آرایه را با الگوهای transducer خود تطبیق دهید.

دوراهی واقعی وقتی پیش می‌آید که شما از پیش در لوله‌کشی transducer مربوط به RxJS قرار دارید. وقتی که بین یک observable و یک آرایه همگام جا به جا می‌شوید، از کدام مورد استفاده می‌کنید؟ من فکر می‌کنم که این انتخاب به گروه شما و پردازش‌های مورد نیاز شما بستگی دارد. اگر به عملگرهای RxJS نیاز دارید، از آن‌ها استفاده کنید. اگر نه، می‌توانید تا زمانی که تعداد زیادی آیتم، یا تغییر شکل‌های پیچیده ندارید، با امنیت کامل از متدهای آرایه بومی JavaScript استفاده کنید.

انواع دیگر مجموعه

Transducerها عمومی هستند و به خود مجموعه اهمیتی نمی‌دهند، بلکه فقط مقادیری که به هر transducer وارد شده و از آن خارج می‌شوند، برایشان مهم است. این یعنی ما از نظر تئوری می‌توانیم از فناوری مشابه به همراه مجموعه‌های دیگر به مانند تکرار کننده‌ها (iteratorها) استفاده کنیم.

بیایید برای مثال از این مولد نمونه استفاده کنیم:

const { from } = require('rxjs')

const { map, toArray } = require('rxjs/operators')

const createExecutableObservable = require('./createExecutableObservable')

const createGenerator = function*() {

  yield 1

  yield 2

  yield 3

  yield 4

}

const value = (

  createExecutableObservable(

    from(createGenerator())

  )

  .pipe(

    map(String),

    toArray(),

  )

  .execute()

)

console.log(value)

// ['1', '2', '3', '4']

جالب است، نه؟ تنها علتی که این کد کار می‌کند، این است که RxJS از تمام مقادیر مولد می‌گذرد، یک آرایه می‌سازد، و مقادیر را یک به یک از مسیر مربوطه می‌گذراند.

نتیجه گیری

با این که RxJS می‌تواند به عنوان یک کتابخانه RxJS استفاده شود، که به یک مدت زمان اجرای بسیار سریع‌تر برای دیتاست‌های بزرگ‌تر ختم می‌شود، اما قطعا تابع subscribe (اشتراک) نحوه کاری نیست که ما می‌خواهیم.

اگر به یادگیری بیشتر درباره آرایه‌ها در JavaScript علاقه‌مند هستید، به این مقالات هم سری بزنید:

منبع

دیدگاه‌ها و پرسش‌ها

برای ارسال نظر لازم است ابتدا وارد سایت شوید
در حال دریافت نظرات از سرور، لطفا منتظر بمانید