آشنایی با متد merge در جاوا 11

در این مقاله، با متد ()Map.merge در جاوا آشنا میشوید. این متد احتمالا مهمترین متد در دنیای کلید/مقدار و در عین حال گمنام و کماستفاده است. متد ()merge میتواند به این صورت توضیح داده شود:
It either puts new value under the given key (if absent) or updates existing key with a given value (UPSERT)
با پایهایترین مثال کارمان را شروع میکنیم: شمارش تعداد کلمات یکتا در متن. تا قبل از جاوا 8 (یعنی قبل از 2014)، کدی که برای این کار نوشته میشد کاملا کثیف بوده و هدف کار در پیادهسازی جزییات گم میشد:
Map<String, Integer> map = new HashMap<>(); for (String word : words) { Integer prev = map.get(word); if (prev == null) { map.put(word, 1); } else { map.put(word, prev + 1); } }
این کد به درستی کار میکند و به ازای ورودی دادهشده، خروجی مورد نظر را تولید میکند:
var words = List.of("Foo", "Bar", "Foo", "Buzz", "Foo", "Buzz", "Fizz", "Fizz"); //... {Bar=1, Fizz=2, Foo=3, Buzz=2}
اما حالا بیایید برای حذف شرطهای منطقی، کد را بازآرایی کنیم:
words.forEach(word -> { map.putIfAbsent(word, 0); map.put(word, map.get(word) + 1); });
بهتر شد.
نقش متد ()putIfAbsent حیاتی است. زیرا در صورت عدم استفاده از این متد، در اولین رخداد یک کلمه جدید، کد خراب میشود و به درستی کار نخواهد کرد. در ضمن، در داخل متد ()map.get از متد ()map.put استفاده شده که یکم خوب نیست. بیاید از شرش خلاص شویم.
words.forEach(word -> { map.putIfAbsent(word, 0); map.computeIfPresent(word, (w, prev) -> prev + 1); });
متد ()computeIfPresent، تنها در صورتی که کلمه مورد نظر (word) وجود داشته باشد، عملیات مورد نظر را انجام میدهد. در غیر این صورت، هیچ کاری نمیکند. البته، با مقداردهی اولیه آن به صفر (0)، مطمئن میشویم که کلید word حتما وجود داشته باشد. به این ترتیب، عملیات افزایش (increment) همیشه کار میکند. آیا میتوانیم بهتر از این هم عمل کنیم؟ خب، میتوانیم مقداردهی اولیه را حذف کنیم، اما چنین کاری اصلا پیشنهاد نمیشود.
words.forEach(word -> map.compute(word, (w, prev) -> prev != null ? prev + 1 : 1) );
متد ()compute هم مانند ()computeIfPresent است اما بدون توجه به وجود کلید دادهشده، فراخوانی میشود. اگر برای کلید دادهشده مقداری وجود نداشت، آرگومان prev برابر با null خواهد بود. وجود یکif ساده در عملگر سهتایی پنهان در لامبدا، از بهینه بودن بسیار فاصله دارد. قبل از این که ورژن نهایی را ببینیم، بیایید پیادهسازی پیشفرضِ نسبتا سادهشدهای از متد ()map.merge را ببینیم:
default V merge(K key, V value, BiFunction<V, V, V> remappingFunction) { V oldValue = get(key); V newValue = (oldValue == null) ? value : remappingFunction.apply(oldValue, value); if (newValue == null) { remove(key); } else { put(key, newValue); } return newValue; }
خواندنِ تکهکد به اندازه هزار کلمه ارزش دارد. ()merge در دو سناریوی متفاوت عمل میکند. اگر کلید دادهشده، موجود نبود، به سادگی تبدیل به (put (key, value میشود. اگر هم کلید مورد نظر از قبل دارای مقداری باشد، متد remappingFunction مقدار قدیمی را با با مقدار جدید ادغام (merge) میکند. این متد میتواند:
- مقدار قدیمی را با مقدار جدید بازنویسی کند: old, new) -> new)
- مقدار قدیمی را نگه دارد و به سادگی همان را برگرداند: old, new) -> old)
- به طریقی، دو مقدار با هم ادغام کند. مثلا: old, new) -> old + new)
- یا حتی مقدار قدیمی را حذف کند: old, new) -> null)
همانطور که میبینید، متد ()merge کاملا همهکاره است. حالا با وجود متد ()merge مساله ما چطور به نظر میآید؟ کاملا خوشایند:
words.forEach(word -> map.merge(word, 1, (prev, one) -> prev + one) );
این کد را به این شکل میتوانید بخوانید: اگر کلمه word از قبل وجود ندارد، عدد 1 را زیرش بنویس. اگر از قبل وجود دارد، عدد 1 را با مقدار موجود جمع کن. یکی از پارامترها را one نامگذاری کردیم چرا که در مثال ما همیشه برابر با 1 است.
متاسفانه، remappingFunction، دو پارامتر دارد. پارامتر دوم، مقداری است که قصد داریم آن را UPSERT (یعنی update یا insert) کنیم. از نظر فنی، این مقدار را از قبل میشناسیم و بنابراین (word, 1, prev -> prev + 1)
قابل درکتر خواهد بود. اما چنین APIای وجود ندارد.
بسیار خب، اما آیا واقعا متد ()merge مفید است؟ فرض کنید که کلاس زیر را برای نگهداری یک عملیاتِ مربوط به حساب داریم (از سازندهها، getterها و سایر ویژگیهای مفید صرف نظر شدهاند):
class Operation { private final String accNo; private final BigDecimal amount; }
و تعداد زیادی عملیات برای حسابهای مختلف داریم:
var operations = List.of( new Operation("123", new BigDecimal("10")), new Operation("456", new BigDecimal("1200")), new Operation("123", new BigDecimal("-4")), new Operation("123", new BigDecimal("8")), new Operation("456", new BigDecimal("800")), new Operation("456", new BigDecimal("-1500")), new Operation("123", new BigDecimal("2")), new Operation("123", new BigDecimal("-6.5")), new Operation("456", new BigDecimal("-600")) );
میخواهیم بدون استفاده از ()merge، برای هر حساب مقدار موجودی (مجموع مقادیر حاصل از عملیاتها) را محاسبه کنیم. کار پرزحمتی است:
var balances = new HashMap<String, BigDecimal>(); operations.forEach(op -> { var key = op.getAccNo(); balances.putIfAbsent(key, BigDecimal.ZERO); balances.computeIfPresent(key, (accNo, prev) -> prev.add(op.getAmount())); });
اما با کمک کوچکی از ()merge، خواهیم داشت:
operations.forEach(op -> balances.merge(op.getAccNo(), op.getAmount(), (soFar, amount) -> soFar.add(amount)) );
البته اینجا میتوانیم از ارجاع متد (method reference) نیز استفاده کنیم:
operations.forEach(op -> balances.merge(op.getAccNo(), op.getAmount(), BigDecimal::add) );
به نظر، این روش بسیار خوانا است: «برای هر عملیات، amount دادهشده را به accNo دادهشده، اضافه کن». همان نتایج مورد انتظار حاصل خواهد شد:
{123=9.5, 456=-100}
ConcurrentHashMap
زمانی که بفهمید ()Map.merge داخل ConcurrentHashMap پیادهسازی شده است، نقش این متد برایتان روشنتر هم خواهد شد. این اتفاق، به این معنی است که میتوانیم یک عملیات درج یا ویرایش را در یک خط و به صورت thread-safe انجام دهیم.
کلاس ConcurrentHashMap واضحا thread-safe است. اما نه بین دو عملیات، مثلا فراخوانیِ ()get و فراخوانیِ ()put بعد از آن.
با این وجود، ()merge اطمینان حاصل میکند که هیچ updateای از دست نرود (lost نشود).
.
.
.
.
.
آدرس کانال تلگرام: JavaCupIR@
آدرس اکانت توییتر: JavaCupIR@
آدرس صفحه اینستاگرام: javacup.ir
آدرس گروه لینکدین: Iranian Java Developers
سلام دوره بعدی مسابقات جاوا کاپ کی هست؟
سلام
به محض قطعی شدن تاریخ، اطلاعرسانی خواهیم کرد. لطفا در شبکههای اجتماعی جاواکاپ، اخبار مربوطه را دنبال کنید.