जावा में कूड़े को कैसे नहीं

एक लोकप्रिय गलत धारणा है कि अगर आपको कचरा संग्रह पसंद नहीं है, तो आपको जावा में नहीं, बल्कि C / C ++ में लिखना होगा। पिछले तीन वर्षों से मैं मुद्रा व्यापार के लिए कम विलंबता जावा कोड लिख रहा हूं, और मुझे हर तरह से अनावश्यक वस्तुओं को बनाने से बचना था। नतीजतन, मैंने अपने लिए कुछ सरल नियम बनाए, कि कैसे जावा में आवंटन कम किया जाए, यदि शून्य नहीं है, तो कुछ उचित न्यूनतम पर, बिना मैनुअल मेमोरी प्रबंधन का सहारा लिए। शायद यह समुदाय के किसी व्यक्ति के लिए भी उपयोगी होगा।


कूड़े से क्यों बचें


जीसी क्या है और उन्हें कैसे कॉन्फ़िगर किया जाए इसके बारे में कहा गया था और बहुत कुछ लिखा गया था। लेकिन अंत में, कोई फर्क नहीं पड़ता कि आप जीसी को कैसे सेट करते हैं, कोड कि कूड़े उप-रूप से काम करेगा। थ्रूपुट और विलंबता के बीच हमेशा एक व्यापार होता है। दूसरे को बिगाड़े बिना किसी को सुधारना असंभव हो जाता है। एक नियम के रूप में, जीसी ओवरहेड को लॉग का अध्ययन करके मापा जाता है - आप उनसे समझ सकते हैं कि किस क्षण में रुके थे और कितना समय लगा था। हालांकि, जीसी लॉग में इस ओवरहेड के बारे में सभी जानकारी नहीं होती है। थ्रेड द्वारा बनाई गई ऑब्जेक्ट स्वचालित रूप से प्रोसेसर कोर के एल 1 कैश में रखा जाता है जिस पर धागा चल रहा है। यह अन्य संभावित उपयोगी डेटा से भीड़ की ओर जाता है। बड़ी संख्या में आवंटन के साथ, उपयोगी डेटा को L3 कैश से भी बाहर निकाला जा सकता है। अगली बार जब थ्रेड इस डेटा को एक्सेस करता है, तो मिस कैश हो जाएगा, जिससे प्रोग्राम के निष्पादन में देरी होगी। इसके अलावा, चूंकि L3 कैश एक ही प्रोसेसर के भीतर सभी कोर के लिए आम है, इसलिए एक कचरा धारा L3 कैश से डेटा और अन्य थ्रेड्स / एप्लिकेशन को धक्का देगी, और वे पहले से ही अतिरिक्त महंगे कैश मिस का सामना करेंगे, भले ही वे नंगे सी में लिखे हों। और कचरा पैदा न करें। इस समस्या से निपटने के लिए कोई सेटिंग, कोई कचरा संग्रहकर्ता (न तो C4, और न ही ZGC) मदद करेगा। एक पूरे के रूप में स्थिति को बेहतर बनाने का एकमात्र तरीका अनावश्यक वस्तुओं को अनावश्यक रूप से बनाना नहीं है। जावा, C ++ के विपरीत, मेमोरी के साथ काम करने के लिए तंत्र का एक समृद्ध शस्त्रागार नहीं है, लेकिन फिर भी, आवंटन को कम करने के कई तरीके हैं। उनसे चर्चा की जाएगी।


गीतात्मक विषयांतर

बेशक, आपको कचरा मुक्त कोड लिखने की आवश्यकता नहीं है। जावा भाषा के बारे में बात यह है कि आप केवल कचरे के मुख्य स्रोतों को हटाकर अपने जीवन को सरल बना सकते हैं। लॉक-फ्री एल्गोरिदम लिखते समय आप सुरक्षित मेमोरी रिक्लेमेशन से भी नहीं निपट सकते। यदि कुछ एप्लिकेशन को एप्लिकेशन स्टार्टअप पर केवल एक बार निष्पादित किया जाता है, तो यह आपको जितना चाहें उतना आवंटित कर सकता है, और यह कोई बड़ी बात नहीं है। और निश्चित रूप से, अतिरिक्त कचरे से छुटकारा पाने के लिए मुख्य कार्य उपकरण आवंटन प्रोफाइलर है।


आदिम प्रकारों का उपयोग करना


कई मामलों में सबसे सरल बात यह हो सकती है कि वस्तु प्रकारों के बजाय आदिम प्रकारों का उपयोग किया जाए। JVM में ऑब्जेक्ट प्रकारों के ओवरहेड को कम करने के लिए कई अनुकूलन होते हैं, जैसे कि पूर्णांक प्रकारों के छोटे मूल्यों को कैशिंग करना और सरल कक्षाओं को सम्मिलित करना। लेकिन ये अनुकूलन हमेशा भरोसे के लायक नहीं होते हैं, क्योंकि वे बाहर काम नहीं कर सकते हैं: एक पूर्णांक मूल्य कैश नहीं हो सकता है, और इनलाइनिंग नहीं हो सकती है। इसके अलावा, जब सशर्त इंटेगर के साथ काम करते हैं, तो हमें लिंक का पालन करने के लिए मजबूर किया जाता है, जो संभावित रूप से कैश मिस हो जाता है। इसके अलावा, सभी ऑब्जेक्ट्स के हेडर हैं जो कैश में अतिरिक्त स्थान लेते हैं, वहां से अन्य डेटा को भीड़ते हैं। आइए इसे लेते हैं: एक आदिम इंट 4 बाइट्स लेता है। ऑब्जेक्ट Integer 16 बाइट्स पर कब्जा कर लेता है + इस इंटेगर के लिंक का आकार 4 बाइट्स न्यूनतम है (संपीड़ित उफ़ के मामले में)। कुल मिलाकर, यह पता चला है कि Integer int तुलना में पांच (!) अधिक जगह int । इसलिए, आदिम प्रकारों का उपयोग करना बेहतर है। मैं कुछ उदाहरण दूंगा।


उदाहरण 1. पारंपरिक गणना


मान लीजिए कि हमारे पास एक नियमित कार्य है जो केवल कुछ गिनता है।


 Integer getValue(Integer a, Integer b, Integer c) { return (a + b) / c; } 

इस तरह के कोड के इनलाइन (विधि और वर्ग दोनों) बनने की संभावना है और इससे अनावश्यक आवंटन नहीं होंगे, लेकिन आप इसके बारे में सुनिश्चित नहीं हो सकते। यदि ऐसा होता है, तो भी इस तथ्य के साथ एक समस्या होगी कि एक NullPointerException यहां से उड़ान भर सकता है। एक तरह से या किसी अन्य, JVM को या तो हुड के नीचे null जांच सम्मिलित करनी होगी, या किसी तरह इस संदर्भ से समझना होगा कि null तर्क के रूप में नहीं आ सकता है। वैसे भी, यह केवल प्राथमिकताओं पर समान कोड लिखना बेहतर है।


 int getValue(int a, int b, int c) { return (a + b) / c; } 

उदाहरण 2. लम्बदा


कभी-कभी वस्तुओं को हमारे ज्ञान के बिना बनाया जाता है। उदाहरण के लिए, यदि हम आदिम प्रकारों को पास करते हैं जहाँ ऑब्जेक्ट प्रकारों की अपेक्षा की जाती है। यह अक्सर लैम्बडा एक्सप्रेशन का उपयोग करते समय होता है।
कल्पना कीजिए कि हमारे पास यह कोड है:


 void calculate(Consumer<Integer> calculator) { int x = System.currentTimeMillis(); calculator.accept(x); } 

इस तथ्य के बावजूद कि चर एक्स एक आदिम है, प्रकार का एक ऑब्जेक्ट बनाया जाएगा इंटेगर, जिसे कैलकुलेटर को पास किया जाएगा। इससे बचने के लिए, Consumer<Integer> बजाय IntConsumer उपयोग करें:


 void calculate(IntConsumer calculator) { int x = System.currentTimeMillis(); calculator.accept(x); } 

ऐसा कोड अब एक अतिरिक्त ऑब्जेक्ट के निर्माण की ओर नहीं ले जाएगा। Java.util.function में आदिम प्रकारों का उपयोग करने के लिए अनुकूलित मानक इंटरफेस का एक पूरा सेट है: DoubleSupplier , LongFunction , आदि। ठीक है, अगर कुछ याद आ रही है, तो आप हमेशा प्राथमिक के साथ वांछित इंटरफ़ेस जोड़ सकते हैं। उदाहरण के लिए, BiConsumer<Integer, Double> बजाय BiConsumer<Integer, Double> आप घर पर बने इंटरफ़ेस का उपयोग कर सकते हैं।


 interface IntDoubleConsumer { void accept(int x, double y); } 

उदाहरण 3. संग्रह


एक आदिम प्रकार का उपयोग करना मुश्किल हो सकता है क्योंकि इस प्रकार का एक चर संग्रह में है। मान लीजिए कि हमारे पास कुछ List<Integer> और हम यह पता लगाना चाहते हैं कि इसमें कौन सी संख्याएँ हैं और गणना करें कि प्रत्येक संख्या कितनी बार दोहराई गई है। इसके लिए हम HashMap<Integer, Integer> । कोड इस तरह दिखता है:


 List<Integer> numbers = new ArrayList<>(); // fill numbers somehow Map<Integer, Integer> counters = new HashMap<>(); for (Integer x : numbers) { counters.compute(x, (k, v) -> v == null ? 1 : v + 1); } 

यह कोड एक साथ कई तरीकों से खराब है। सबसे पहले, यह एक मध्यवर्ती डेटा संरचना का उपयोग करता है, जो शायद बिना किया जा सकता है। ठीक है, ठीक है, सादगी के लिए, हम मानते हैं कि इस सूची की आवश्यकता बाद में होगी, अर्थात्। आप इसे पूरी तरह से हटा नहीं सकते। दूसरे, ऑब्जेक्ट Integer उपयोग दोनों जगहों पर आदिम int बजाय किया जाता है। तीसरे, compute पद्धति में कई आवंटन हैं। चौथा, एक इट्रेटर आवंटित किया जाता है। यह आवंटन इनलाइन बनने की संभावना है, लेकिन फिर भी। इस कोड को कचरा मुक्त कोड में कैसे बदलें? आपको बस कुछ तृतीय-पक्ष लाइब्रेरी से प्राइमिटिव्स पर संग्रह का उपयोग करने की आवश्यकता है। इस तरह के संग्रह में कई पुस्तकालय हैं। कोड का निम्नलिखित टुकड़ा एग्रोन लाइब्रेरी का उपयोग करता है।


 IntArrayList numbers = new IntArrayList(); // fill numbers somehow Int2IntCounterMap counters = new Int2IntCounterMap(0); for (int i = 0; i < numbers.size(); i++) { counters.incrementAndGet(numbers.getInt(i)); } 

यहाँ बनाई गई वस्तुएँ दो संग्रह और दो int[] , जो इन संग्रहों के अंदर स्थित हैं। उन पर clear() पद्धति को बुलाकर दोनों संग्रहों का पुन: उपयोग किया जा सकता है। आदिम पर संग्रह का उपयोग करते हुए, हमने अपने कोड को जटिल नहीं किया (और इसके अंदर एक जटिल लंबोदर के साथ गणना विधि को हटाकर इसे सरल भी किया) और मानक संग्रह का उपयोग करने की तुलना में निम्नलिखित अतिरिक्त बोनस प्राप्त किया:


  1. आवंटन की लगभग पूर्ण अनुपस्थिति। यदि संग्रह का पुन: उपयोग किया जाता है, तो कोई भी आवंटन नहीं होगा।
  2. महत्वपूर्ण स्मृति बचत ( IntArrayList , ArrayList<Integer> तुलना में लगभग पाँच गुना कम जगह लेती है। जैसा कि पहले ही उल्लेख किया गया है, हम प्रोसेसर कैश के किफायती उपयोग की परवाह करते हैं, रैम की नहीं।
  3. स्मृति तक सीरियल की पहुंच। इस विषय पर बहुत कुछ लिखा गया है कि यह महत्वपूर्ण क्यों है, इसलिए मैं वहाँ नहीं रुकता। यहाँ कुछ लेख हैं: मार्टिन थॉम्पसन और उलरिक ड्रेपर

संग्रह के बारे में एक और छोटी टिप्पणी। यह पता चल सकता है कि संग्रह में विभिन्न प्रकार के मूल्य शामिल हैं, और इसलिए इसे संग्रह के साथ आदिम के साथ बदलना संभव नहीं है। मेरी राय में, यह समग्र रूप से डेटा संरचना या एल्गोरिथ्म के खराब डिज़ाइन का संकेत है। इस मामले में सबसे अधिक संभावना है, अतिरिक्त वस्तुओं का आवंटन मुख्य समस्या नहीं है।


परस्पर वस्तु


लेकिन क्या होगा अगर आदिम के साथ तिरस्कार नहीं किया जा सकता है? उदाहरण के लिए, इस घटना में कि हमें जिस विधि की आवश्यकता है, उसे कई मान वापस करने चाहिए। उत्तर सरल है - परस्पर वस्तुओं का उपयोग करें।


छोटा विषयांतर

कुछ भाषाएं, स्केला में उदाहरण के लिए अपरिवर्तनीय वस्तुओं के उपयोग पर जोर देती हैं। उनके पक्ष में मुख्य तर्क यह है कि मल्टीथ्रेडेड कोड लिखना बहुत सरल है। हालांकि, कचरे के अत्यधिक आवंटन से जुड़े ओवरहेड्स भी हैं। यदि हम उनसे बचना चाहते हैं, तो हमें अल्पकालिक अपरिवर्तनीय वस्तुओं का निर्माण नहीं करना चाहिए।


व्यवहार में यह कैसा दिखता है? मान लें कि हमें भागफल और शेष भाग की गणना करने की आवश्यकता है। और इसके लिए हम निम्नलिखित कोड का उपयोग करते हैं।


 class IntPair { int x; int y; } IntPair divide(int value, int divisor) { IntPair result = new IntPair(); result.x = value / divisor; result.y = value % divisor; return result; } 

इस मामले में आवंटन से कैसे छुटकारा पाया जा सकता है? यह सही है, IntPair को एक तर्क के रूप में पास करें और वहां परिणाम लिखें। इस मामले में, आपको एक विस्तृत javadoc लिखने की आवश्यकता है, और इससे भी बेहतर, चर नामों के लिए किसी प्रकार के सम्मेलन का उपयोग करें, जहां परिणाम लिखा गया है। उदाहरण के लिए, वे उपसर्ग के साथ शुरू कर सकते हैं। इस मामले में कचरा मुक्त कोड इस तरह दिखेगा:


 void divide(int value, int divisor, IntPair outResult) { outResult.x = value / divisor; outResult.y = value % divisor; } 

मैं यह नोट करना चाहता हूं कि divide विधि को जोड़ी के लिंक को कहीं भी सहेजना नहीं चाहिए या इसे उन तरीकों से पास करना चाहिए जो ऐसा कर सकते हैं, अन्यथा हमें बड़ी समस्याएं हो सकती हैं। जैसा कि हम देख सकते हैं, कि उत्परिवर्तित वस्तुएं आदिम प्रकारों की तुलना में उपयोग करने के लिए कठिन हैं, इसलिए यदि आप आदिम का उपयोग कर सकते हैं, तो ऐसा करना बेहतर है। वास्तव में, हमारे उदाहरण में, हमने विभाजन विधि के भीतर से आवंटन की समस्या को बाहर स्थानांतरित कर दिया। सभी स्थानों पर जहां हम इस पद्धति को कहते हैं, हमें कुछ IntPair डमी की आवश्यकता होगी, जिसे हम divide करने के लिए पास करेंगे। अक्सर इस डमी को ऑब्जेक्ट के final क्षेत्र में संग्रहीत करने के लिए पर्याप्त है, जहां से हम divide विधि कहते हैं। मैं आपको एक दूरगामी उदाहरण देता हूं: मान लीजिए कि हमारा कार्यक्रम केवल नेटवर्क पर संख्याओं की एक धारा प्राप्त करने से संबंधित है, उन्हें विभाजित करता है, और परिणाम को उसी सॉकेट में भेजता है।


 class SocketListener { private final IntPair pair = new IntPair(); private final BufferedReader in; private final PrintWriter out; SocketListener(final Socket socket) throws IOException { in = new BufferedReader(new InputStreamReader(socket.getInputStream())); out = new PrintWriter(socket.getOutputStream(), true); } void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); divide(value, divisor, pair); out.print(pair.x); out.print(pair.y); } } } 

संक्षिप्तता के लिए, मैंने त्रुटि से निपटने, सही कार्यक्रम समाप्ति, आदि के लिए "अतिरिक्त" कोड नहीं लिखा। कोड के इस टुकड़े का मुख्य विचार यह है कि हमारे द्वारा IntPair की जाने वाली IntPair ऑब्जेक्ट को एक बार बनाया जाता है और final फ़ील्ड में संग्रहीत किया जाता है।


ऑब्जेक्ट पूल


जब हम उत्परिवर्तित वस्तुओं का उपयोग करते हैं, तो हमें पहले कहीं से एक खाली वस्तु लेनी चाहिए, फिर हमें जिस डेटा की आवश्यकता है उसे लिखें, इसे कहीं उपयोग करें, और फिर ऑब्जेक्ट को "जगह" पर लौटाएं। उपरोक्त उदाहरण में, ऑब्जेक्ट हमेशा "जगह" में था, अर्थात final क्षेत्र में। दुर्भाग्य से, यह हमेशा एक सरल तरीके से करना संभव नहीं है। उदाहरण के लिए, हम पहले से नहीं जान सकते कि हमें कितनी वस्तुओं की आवश्यकता है। इस मामले में, ऑब्जेक्ट पूल हमारी सहायता के लिए आते हैं। जब हमें एक खाली वस्तु की आवश्यकता होती है, तो हम इसे ऑब्जेक्ट पूल से प्राप्त करते हैं, और जब इसकी आवश्यकता समाप्त हो जाती है, तो हम इसे वापस कर देते हैं। यदि पूल में कोई मुफ्त वस्तु नहीं है, तो पूल एक नई वस्तु बनाता है। यह वास्तव में सभी आगामी परिणामों के साथ एक मैनुअल मेमोरी प्रबंधन है। यह सलाह दी जाती है कि इस पद्धति का सहारा न लें यदि पिछले तरीकों का उपयोग करना संभव है। क्या गलत हो सकता है?


  • हम पूल में ऑब्जेक्ट को वापस करने के लिए भूल सकते हैं, और फिर कचरा ("मेमोरी लीक") बनाया जाएगा। यह एक छोटी समस्या है - प्रदर्शन थोड़ा कम हो जाएगा, लेकिन जीसी बाहर काम करेगा और कार्यक्रम काम करना जारी रखेगा।
  • हम पूल में ऑब्जेक्ट को वापस कर सकते हैं, लेकिन लिंक को कहीं और सेव कर सकते हैं। फिर किसी और को पूल से ऑब्जेक्ट मिलेगा, और हमारे कार्यक्रम में इस बिंदु पर पहले से ही एक ही ऑब्जेक्ट के दो लिंक होंगे। यह एक क्लासिक उपयोग-बाद-मुक्त समस्या है। यह पहली बार में कठिन है क्योंकि C ++ के विपरीत, प्रोग्राम क्रैश नहीं होगा और गलत तरीके से काम करना जारी रखेगा।

उपरोक्त त्रुटियां करने की संभावना को कम करने के लिए, आप मानक कोशिश-के-संसाधन निर्माण का उपयोग कर सकते हैं। यह इस तरह लग सकता है:


 public interface Storage<T> { T get(); void dispose(T object); } class IntPair implements AutoCloseable { private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new); int x; int y; private IntPair() {} public static IntPair create() { return STORAGE.get(); } @Override public void close() { STORAGE.dispose(this); } } 

विभाजन विधि इस तरह दिख सकती है:


 IntPair divide(int value, int divisor) { IntPair result = IntPair.create(); result.x = value / divisor; result.y = value % divisor; return result; } 

और listenSocket विधि इस तरह है:


 void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); try (IntPair pair = divide(value, divisor)) { out.print(pair.x); out.print(pair.y); } } } 

IDE में, आप आमतौर पर सभी मामलों के हाइलाइटिंग को कॉन्फ़िगर कर सकते हैं जब AutoCloseable ऑब्जेक्ट्स को try-with-resource ब्लॉक के बाहर उपयोग किया जाता है। लेकिन यह एक पूर्ण विकल्प नहीं है, क्योंकि IDE में हाइलाइटिंग को बंद किया जा सकता है। इसलिए, पूल पर नियंत्रण - उलटा वस्तु की वापसी की गारंटी देने का एक और तरीका है। मैं एक उदाहरण दूंगा:


 class IntPair implements AutoCloseable { private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new); int x; int y; private IntPair() {} private static void apply(Consumer<IntPair> consumer) { try(IntPair pair = STORAGE.get()) { consumer.accept(pair); } } @Override public void close() { STORAGE.dispose(this); } } 

इस स्थिति में, हम मूल रूप से IntPair वर्ग की वस्तु IntPair बाहर IntPair एक्सेस नहीं कर सकते हैं। दुर्भाग्य से, यह विधि भी हमेशा काम नहीं करती है। उदाहरण के लिए, यह काम नहीं करेगा यदि एक धागा पूल से वस्तुओं को प्राप्त करता है और इसे एक कतार में रखता है, और दूसरा धागा उन्हें कतार से बाहर ले जाता है और पूल में लौटता है।


जाहिर है, अगर हम पूल में जेनेरिक ऑब्जेक्ट्स को स्टोर नहीं करते हैं, लेकिन कुछ लाइब्रेरी ऑब्जेक्ट्स जो AutoCloseable लागू नहीं करते हैं, तो कोशिश-के साथ-संसाधन विकल्प भी काम नहीं करेंगे।


यहां एक अतिरिक्त समस्या बहुस्तरीय है। ऑब्जेक्ट पूल का कार्यान्वयन बहुत तेज़ होना चाहिए, जिसे प्राप्त करना काफी कठिन है। एक अच्छा पूल प्रदर्शन को अच्छे से अधिक नुकसान पहुंचा सकता है। बदले में, टीएलएबी में नई वस्तुओं का आवंटन बहुत तेज है, सी में मॉलॉक की तुलना में बहुत तेज है। एक फास्ट ऑब्जेक्ट पूल लिखना एक अलग विषय है जिसे मैं अब विकसित नहीं करना चाहता हूं। मैं केवल यह कह सकता हूं कि मैंने कोई भी "तैयार-तैयार" कार्यान्वयन नहीं देखा है।


एक निष्कर्ष के बजाय


संक्षेप में, ऑब्जेक्ट पूल के साथ पुन: उपयोग करना गंभीर बवासीर है। सौभाग्य से, लगभग हमेशा आप इसके बिना कर सकते हैं। मेरा व्यक्तिगत अनुभव यह है कि ऑब्जेक्ट पूल का अत्यधिक उपयोग एप्लिकेशन आर्किटेक्चर के साथ समस्याओं का संकेत देता है। एक नियम के रूप में, final क्षेत्र में कैश की गई वस्तु का एक उदाहरण हमारे लिए पर्याप्त है। लेकिन यहां तक ​​कि अगर यह आदिम प्रकारों का उपयोग करना संभव है, तो यह ओवरकिल है।


अपडेट:


हां, मुझे उन लोगों के लिए एक और तरीका याद आया, जो बिटवाइज़ शिफ्ट्स से नहीं डरते: कई छोटे आदिम प्रकारों को एक बड़े में पैक करना। मान लें कि हमें दो int को वापस करने की आवश्यकता है। इस विशेष मामले में, आप IntPair ऑब्जेक्ट का उपयोग नहीं कर सकते हैं, लेकिन एक long लौटें, पहली 4 बाइट्स जिसमें पहली int 'y, और दूसरी 4 बाइट्स दूसरी के अनुरूप होंगी। कोड इस तरह दिख सकता है:


 long combine(int left, int right) { return ((long)left << Integer.SIZE) | (long)right & 0xFFFFFFFFL; } int getLeft(long value) { return (int)(value >>> Integer.SIZE); } int getRight(long value) { return (int)value; } long divide(int value, int divisor) { int x = value / divisor; int y = value % divisor; return combine(left, right); } void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); long xy = divide(value, divisor); out.print(getLeft(xy)); out.print(getRight(xy)); } } 

इस तरह के तरीकों, निश्चित रूप से, पूरी तरह से परीक्षण करने की आवश्यकता है, क्योंकि उन्हें लिखना बहुत आसान है। लेकिन उसके बाद ही इसका इस्तेमाल करें।

Source: https://habr.com/ru/post/hi436024/


All Articles