Wednesday, September 24, 2014

Java 8'de Torbalar Üzerinde "Paralel Yapılar" Kurulması

   
Verilerin işlenebilmesi için bellekte organize edilmeleri gerekir. Bunun için çeşitli organizasyon biçimleri bulunuyor: Dizi, Bağlantılı Liste, Yığın, Kuyruk. Java'da bu organizasyon biçimlerinin çok daha fazlası Collection API içinde bulunur ve uygulama geliştirilirken pek çoğu kullanılır. Torba seçimi, veriye nasıl ve hangi amaçla erişileceğine bağlıdır. Eğer çoğu zaman rastgele ve daha çok okumak amacıyla, konumuna göre rasgele erişiliyorsa ArrayList, ama rasgele silme/ekleme yapılacak ve daha çok ardışık olarak erişilecekse LinkedList kullanılmalıdır. Eğer çift değerlere izin verilmiyorsa, Set arayüzüne sahip bir torba kullanılmalıdır. Bu noktada, ekleme sırası önemliyse, LinkedHashSet’in, her zaman sıralı erişilecekse TreeSet’in ve daha çok verilerin torbada var olup olmadığı sorgulanacaksa HashSet’in kullanımı uygulama başarımı için tercih edilir. Eğer veriler (Anahtar ve Değer) ikililerinden oluşuyorsa Map arayüzüne sahip bir torbanın kullanılması gerekir. Map verilere anahtar üzerinden erişilmesini sağlar, verilen anahtar için o anahtar değerli değer döndürülür. Eğer anahtarların eklenme sırası önemliyse, LinkedHashMap, her zaman sıralı erişilecekse TreeMap ve daha çok verilerin torbada var olup olmadığı sorgulanacaksa HashMap tercih edilir.  Collection API içinde bunlar dışında pek çok sayıda daha torba bulunur. Java geliştiricilerinden torbaların detaylarını iyi derecede biliyor olması beklenir.
   Torba içindeki verilere ardışık olarak erişim için iki yöntem bulunmaktadır:
1. for-each Kullanımı
for(:) gösterimi Java programlama diline Java SE 5 ile gelmiştir:
List<City> cities;
for (City city: cities){
     System.out.println(city.getName());
}
Burada city değişkeni sırayla cities torbasındaki tüm nesneleri ziyaret eder. for-each gösterimi salt okunurdur, gözlerini ziyaret ettiği torba üzerinde herhangi bir değişikliğe neden olamaz. Torbadaki tüm elemanlar taranır, eleman atlamak mümkün değildir. Eğer torbada değişiklik yapılacak ise Iterator<T> kullanılmalıdır.
2. Iterator<T> Kullanımı
List<City> cities;
Iterator<City> iter= cities.iterator();
while (iter.hasNext()){
    City city= iter.next();
    System.out.println(city.getName());       
}            
   Torbalar veriye erişim için List<T>, Set<T>, SortedSet<T>, Map<K,V> gibi arayüzler üzerinden iyi bir soyutlama sunar. Hangi torba kullanılırsa kullanılsın veriye aynı şekilde erişilir: eleman eklemek için add()/put() metodu, silmek için ise remove() metodu çağırılır. Bazı işlemler, torbadaki tüm verilere erişimi gerektirir. Örneğin, torbadaki en büyük ve en küçük değerlerin bulunması ya da ortalama hesabı gibi işlemler için torbadaki tüm verilere erişmek gerekir. Eğer bu işlem, yukarıda (1)’de ve (2)’de tanıtılan for-each ve Iterator<T> kullanılarak gerçekleştirilirse, seri olarak yürütülür. (1) ve (2) ile gerçekleştirilen döngü, döngü kurma işlemi torbanın dışında gerçekleştiği için dış döngü olarak adlandırılmaktadır. Dış döngünün bazı yitimleri vardır. En önemli yitim, çok şekilli (=polymorphic) çözümler kuramıyor olmamızdır. Seri çözümde işlem süresi, özellikle çok elemanlı torbalarda uzar. Daha hızlı sonuç almak için paralel bir çözüm tasarlamak gerekir. Java'da Callable iplikler ve ExecutorService kullanarak ve torbadaki verileri bu ipliklere (=Thread) dağıtarak paralel programlama yapmak mümkündür. Ancak yarış durumu yaratmadan ve ölümcül kilitlenme oluşturmadan yüksek başarımlı bir çözüme ulaşmak her zaman kolay değildir. Üstelik bu çözümün testini yapmak oldukça zordur. Java 8, torbalar üzerinde seri ya da paralel olarak gerçekleştirilecek işlemler için kodlama ve test süresini çok kısaltacak yenilikler sunuyor. Bu yeniliklerin bir kısmı dil seviyesinde, bir kısmı API seviyesinde ve bir kısmı da Java Sanal Makinası seviyesindedir.
   Java 8 ile birlikte yeni bir anlayışa (=Paradigm) kavuştuk: Fonksiyonel programlama. Java’da bir fonksiyona parametre olarak bir değer geçilebilir. Bu değer bir temel tipten olabileceği gibi bir nesne referansı da olabilir:
List<City> findAllCitiesByCountry(Country country){
 . . .
}
Yukarıdaki örnekte findAllCitiesByCountry fonksiyonu Country tipinden bir nesnenin referansını parametre olarak almaktadır. Benzer şekilde List<City> tipinden de bir değer dönmektedir. Fonksiyonel programlama ile birlikte fonksiyonlar parametre olarak, değer dışında fonksiyon da alabilirler veya fonksiyon da döndürebilirler. Bu şekilde parametre olarak fonksiyon alan ya da fonksiyon döndüren fonksiyonları yüksek dereceden fonksiyon (high-order functions,  bazen functional ya da functor) olarak adlandırıyoruz.
   Java 8’de torbalar üzerinde iç döngü oluşturabiliriz:
List < City > allCities = worldDao.findAllCities();
allCities.forEach(
  new Consumer < City > () {
      @Override
      public void accept(City city) {
        System.out.println(city.getName());
      }
  }
);
Dikkat edilirse Collection arayüzünde artık yeni bir metod yer alıyor: forEach(). forEach() ile iç döngü oluşturabilir. Fonksiyona parametre olarak Consumer<City> tipinden anonim sınıftan bir nesne gönderdik. forEach torbadaki her bir nesne için parametre olarak verdiğimiz anonim nesnenin accept() fonksiyonunu çağırır. Şimdi önce Consumer<T> arayüzünün tanımına bir bakalım:
@FunctionalInterface
public interface Consumer<T> {
  void accept(T t);
    default Consumer<T> andThen(Consumer<? super T> after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
}
Bu tanımda bizim için Java 8 ile birlikte gelen iki önemli yenilik yer alıyor:
1.  Fonksiyonel Arayüz (=Functional Interface)
Bir arayüz içinde sadece tek bir fonksiyon bulunuyorsa bu tür arayüzler fonksiyonel arayüz olarak isimlendirilir. Java 8’de bu tür arayüzleri betimlemek üzere @FunctionaInterface isimli damga kullanılır. Bu tür arayüzlere örnek olarak Runnable, Callable, Comparator, Comparable arayüzleri verilebilir. @FunctionaInterface damgasının tanımı ise aşağıda verilmiştir:
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface FunctionalInterface {}
2    2. default tanımlı varsayılan metod
default anahtar kelimesi yeni değil, ancak buradaki kullanımı yeni. Java 8’de API seviyesinde yenilik yapılırken aynı zamanda geriye doğru uyumluluğu da sağlayabilmek için arayüzlerde gövdesi tanımlanan somut fonksiyon yazılabilmesine olanak sağlanmıştır. Arayüzler, hizmet alan ve hizmet veren sınıf arasında birer sözleşme olarak düşünebilir. Sözleşmeye yeni bir metod eklendiğinde kod kırılacaktır. Varsayılan metodlar, arayüze yeni bir metod eklenirken, kodun kırılmasını engeller. Arayüzü gerçekleyen sınıf, eklenen yeni metoda işlev yüklememiş ise varsayılan metod tanımı kullanılır. Ancak bunun C++’dan bildiğimiz yan etkileri bulunmaktadır. Bu nedenle, varsayılan metodların yukarıda anlatılan amaca yönelik olarak kullanımına dikkat edilmelidir.
Fonksiyonel programlamada kısa süreli kullanıma sahip anonim fonksiyonlar l ifadesi olarak adlandırılır. Java 8’da l ifadesi oluşturulabilir. Yukarıda torba üzerinde oluşturduğumuz iç döngüyü l ifadesi olarak da yazabiliriz:
List<City> allCities=worldDao.findAllCities();
allCities.forEach( 
     (final City city) -> {      
        System.out.println(city); 
     }
);

Burada (final City city) ile l ifadesinin parametre listesini veriyoruz. l ifadesinin gövdesini ise metod gövdelerini tanımlarken kullandığımız kıvırcık parantezlerden yararlanıyoruz. Her l ifadesi mutlaka bir fonksiyonel arayüze bağlıdır, bu arayüz üzerinden tanımlanır. Ancak bizim doğrudan arayüzü belirtmemize gerek yoktur. Bu örnekte fonksiyonel arayüz Consumer<T> arayüzüdür. l ifadesinin gövdesine ise fonksiyonel arayüzün biricik metodu üzerinden erişilecektir. Fonksiyonel programlama dillerinde l ifadelerinin uygulamanın durumunu değiştirmezler. for-each ile oluşturulan dış döngüde olduğu gibi torbada değişikliğe neden olamazlar. Yukarıdaki l ifadesinde parametrenin final tanımlanmasındaki amaç budur. Ancak Java 8’deki  l ifadelerinde final kullanımı zorunlu değildir.  
Java 7 ile birlikte derleyici daha akıllı davranmaya başladı. Burada l ifadesini tanımlarken parametrenin tipini söylememize gerek yok, derleyici çıkarsama yapabilir:
List<City> allCities=worldDao.findAllCities();
allCities.forEach( 
     (city) -> {      
        System.out.println(city); 
     }
);
Ancak burada tipi çıkarsama işini derleyiciye bıraktığımızda, artık parametre final tanımlı değildir.
Eğer l ifadesi tek bir parametre alıyor ise parantez çiftini de kullanmaya gerek yoktur:
List<City> allCities=worldDao.findAllCities();
allCities.forEach( 
     city -> {      
        System.out.println(city); 
     }
);
Eğer l ifadesinin gövdesi tek bir ifadeden oluşuyor ise kıvırcık parantez çifti kullanılmayabilir:
List<City> allCities=worldDao.findAllCities();
allCities.forEach( city -> System.out.println(city) );
Eğer l ifadesi sadece bir metod çağrısından oluşuyorsa Java 8 ile birlikte gelen metod tutacağı ile ifade edilebilir:
allCities.forEach( System.out::println );
l ifadeleri ve metod tutacağı Java 8’de fonksiyonel programlama yapmak için temel yapı taşlarını oluşturur. 
Java 8 ile birlikte yukarıda tanıtıldığı gibi iç döngüler oluşturabiliyoruz. Bunu sağlayan Stream API’dır ve bunun ilk uygulamasını da Collection API’de görüyoruz. Ancak Collection API’deki kullanımına geçmeden önce Stream API’nin ne işe yaradığını gösterir bir örnek inceleyelim:
int lost[]={42,23,16,15,8,4};
IntStream.of(lost).sorted().forEach(System.out::println);
OptionalDouble average= IntStream.of(lost).average();
average.ifPresent(System.out::println);
System.out.println("Sum: "+IntStream.of(lost).sum());
Yukarıdaki kodu çalıştırdığımızda ekranda aşağıdaki çıktıyı elde ediyoruz:
4
8
15
16
23
42
18.0
Sum: 108
Stream bir iş hattı gibi düşünülebilir. Yukarıdaki kodda kullanılan IntStream sınıfı ise özel bir Stream sınıfı: iş hattından sadece tam sayılar akıyor. Bu tam sayılar akarken yapılacak işleri bir metod zinciri olarak verebiliyoruz: sorted().forEach(System.out::println). Önce sıralamasını ve sonra ekrana yazmasını istiyoruz. IntStream.of(lost).average() satırında ise akan sayıların ortalamasını hesaplamak istiyoruz. average metodu doğrudan double bir değer dönmek yerine OptionalDouble sınıfından bir değer dönüyor. Java 8’de ile gelen Optional<T> sınıflarının amacı NullPointerException oluşmasını engellemektir. average.ifPresent(System.out::println) ifadesi hiçbir zaman NullPointerException’a neden olmaz.
Şimdi torbalar üzerinde Stream API’yi kullanımını çeşitli örnekler üzerinden çalışalım.
1.  Elimizde filmlerin olduğu bir liste var ve bu liste içinden 70’li yıllara ait Drama türünde filmlere ulaşmak istiyoruz:
MovieService movieService= InMemoryMovieService.getInstance();
Collection<Movie> allMovies= movieService.findAllMovies();
Predicate<Movie> _70s= m -> m.getYear()>=1970 && m.getYear()<1980;
Predicate<Movie> drama= 
    movie -> movie.getGenres().stream()
             .filter(genre -> genre.getName().equals("Drama"))
             .count()==1;
 
// 70’li yıllara ait drama türünde filmlerin listesi
allMovies.stream().filter(_70s.and(drama)).forEach(System.err::println);
// 70’li yıllara ait sadece drama türünden filmlerin listesi
Predicate<Movie> drama_only= 
   movie -> movie.getGenres().size()==1 
   &&   
   movie.getGenres().get(0).getName().equals("Drama");
allMovies.stream().filter(_70s.and(drama_only))
                  .forEach(System.err::println);
Uygulama çalıştırıldığında ekran görüntüsü aşağıdaki gibi gerçekleşmektedir:
70's movies whose genre is Drama
Movie [title=Dog Day Afternoon, year=1975]
Movie [title=Network, year=1976]
Movie [title=The Little Girl Who Lives Down the Lane, year=1976]
Movie [title=Der amerikanische Freund, year=1977]
Movie [title=The Last Wave, year=1977]
70's movies whose genre is only Drama
Movie [title=Network, year=1976]

2. Filmleri yıllara göre ve daha sonra aynı yıllara ait filmleri kendi içinde sözlük sırasına göre listelemek istiyoruz:
MovieService movieService= InMemoryMovieService.getInstance();
Collection<Movie> allMovies= movieService.findAllMovies();
Comparator<Movie> byYear= (left,right) -> left.getYear() - right.getYear() ;
Comparator<Movie> byTitle= 
             (left,right) -> left.getTitle().compareTo(right.getTitle()) ;
allMovies.stream().sorted(byYear.thenComparing(byTitle))
                  .forEach(System.err::println);
Uygulama çalıştırıldığında ekran görüntüsü aşağıdaki gibi gerçekleşmektedir:
Movie [title=The Return of Frank James, year=1940]
Movie [title=Double Indemnity, year=1944]
Movie [title=The Treasure of the Sierra Madre, year=1948]
Movie [title=Sunset Blvd., year=1950]
Movie [title=A Streetcar Named Desire, year=1951]
Movie [title=Stalag 17, year=1953]
Movie [title=Dial M for Murder, year=1954]
Movie [title=Shichinin no samurai, year=1954]
Movie [title=Them!, year=1954]
Movie [title=Vertigo, year=1958]
. . .
Movie [title=The Book of Eli, year=2010]
Movie [title=The Bounty Hunter, year=2010]
Movie [title=Unthinkable, year=2010]
Movie [title=Veda, year=2010]
Movie [title=Yip Man 2: Chung si chuen kei, year=2010]
Movie [title=You Dont Know Jack, year=2010]
3. Birden fazla yönetmeni olan filmleri listelemek istiyoruz:
Consumer<Movie> printNamesAndNumberOfDirectors= 
 movie -> System.err.println(String.format("Title: %-36s # of directors: %d", 
          movie.getTitle(),
          movie.getDirectors().size())) ;
Predicate<Movie> multiDirectedMovies= 
       movie -> movie.getDirectors().size()>1;
allMovies.stream().filter(multiDirectedMovies)
                  .forEach(printNamesAndNumberOfDirectors);
Uygulama çalıştırıldığında ekran görüntüsü aşağıdaki gibi gerçekleşmektedir:
Title: Gamer                                # of directors: 2
Title: A Serious Man                        # of directors: 2
Title: Rembetiko                            # of directors: 2
Title: New York, I Love You                 # of directors: 2
Title: Daybreakers                          # of directors: 2
Title: The Imaginarium of Doctor Parnassus  # of directors: 2
Title: Cloudy with a Chance of Meatballs    # of directors: 2
Title: The Princess and the Frog            # of directors: 2
Title: Karamazovi                           # of directors: 2
Title: The Missing Person                   # of directors: 2
Title: Sonbahar                             # of directors: 2
Title: I Love You Phillip Morris            # of directors: 2
Title: The Book of Eli                      # of directors: 2
4.  Birden fazla yönetmeni olan filmlerin sadece yönetmenlerinin adlarını listelemek istiyoruz:
Consumer<Director> printDirectorName= 
       director -> System.err.println(director.getName()); 
Consumer<List<Director>> printDirectorNames= 
       directors -> directors.stream().forEach(printDirectorName); 
Function<Movie,List<Director>> directorMapper= 
       movie -> movie.getDirectors() ;
allMovies.stream().filter(multiDirectedMovies).map(directorMapper).forEach(printDirectorNames);
Uygulama çalıştırıldığında ekran görüntüsü aşağıdaki gibi gerçekleşmektedir:
Mark Neveldine
Brian Taylor
Ethan Coen
Joel Coen
. . .
Glenn Ficarra
John Requa
Albert Hughes
Allen Hughes
5.  Elimizde tüm dünya ülkelerinin olduğu bir liste var ve kıtaların listesini ekrana sözlük sırasına göre sıralı yazmak istiyoruz:
WorldDao worldDao= InMemoryWorldDao.getInstance();
List<String> continents= 
        worldDao.findAllCountries()
                .stream()
                .map(Country::getContinent)
                .distinct()
                .sorted(String::compareTo)
                .collect(ArrayList::new,ArrayList::add,ArrayList::addAll);
continents.forEach(System.out::println);
Uygulama çalıştırıldığında ekran görüntüsü aşağıdaki gibi gerçekleşmektedir:
Africa
Antarctica
Asia
Europe
North America
Oceania
South America
6. Nüfusu en kalabalık olan ülkeyi bulmak istiyoruz:
Comparator<Country> byPopulation=
        (left,right) -> left.getPopulation()>=right.getPopulation() ? +1 : -1 ;
Optional<Country> overPopulatedCountry= 
        worldDao.findAllCountries()
                .stream()
                .max(byPopulation);
overPopulatedCountry.ifPresent(System.out::println);
Uygulama çalıştırıldığında ekran görüntüsü aşağıdaki gibi gerçekleşmektedir:
Country [code=CHN, name=China, continent=Asia, surfaceArea=9572900.0, population=1277558000, . . . ] 
7. Nüfusu en kalabalık ilk 10 ülkeyi bulmak istiyoruz:
Comparator<Country> byPopulation = 
        (left, right) -> left.getPopulation() >= right.getPopulation() ? -1 : +1;
List<Country> overPopulatedCountries
        = worldDao.findAllCountries()
        .stream()
        .sorted(byPopulation)
        .limit(10)
        .collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
overPopulatedCountries.forEach(System.out::println);
Uygulama çalıştırıldığında ekran görüntüsü aşağıdaki gibi gerçekleşmektedir:
Country [ name=China, population=1277558000]
Country [ name=India, population=1013662000]
Country [ name=United States, population=278357000]
Country [ name=Indonesia, population=212107000]
Country [ name=Brazil, population=170115000]
Country [ name=Pakistan, population=156483000]
Country [ name=Russian Federation, population=146934000]
Country [ name=Bangladesh, population=129155000]
Country [ name=Japan, population=126714000]
Country [ name=Nigeria, population=111506000]

8. Ülkeleri kıtalara göre gruplamak ve her kıtada kaç ülke olduğunu ekrana yazmak istiyoruz:
WorldDao worldDao = InMemoryWorldDao.getInstance();
Map<String,List<Country>> countriesByContinent= worldDao.findAllCountries()
        .stream()
        .collect(Collectors.groupingBy(Country::getContinent));
countriesByContinent.forEach( (key , value) ->  
System.out.println(key + ": " + value.size()));
Uygulama çalıştırıldığında ekran görüntüsü aşağıdaki gibi gerçekleşmektedir:
South America: 14
Asia: 51
Europe: 46
Africa: 58
Antarctica: 5
North America: 37
Oceania: 28
9. Film türlerinin her birinden kaç film olduğunu ekrana yazdırmak istiyoruz:
public static void main(String[] args) {
    MovieService movieService = InMemoryMovieService.getInstance();
    Collection<Movie> allMovies = movieService.findAllMovies();
    Map<String, List<Movie>> moviesByGenre
            = allMovies.stream()
            .flatMap(movie -> movie.getGenres()
                    .stream()
                    .map(genre -> pair(genre.getName(), movie)))
            .collect(groupingBy(Entry::getKey,mapping(Entry::getValue,toList())));
    moviesByGenre.forEach( (key,value) -> System.out.println(key+": "+value.size() ));
}

private static <T, U> AbstractMap.SimpleEntry<T, U> pair(T t, U u) {
    return new AbstractMap.SimpleEntry<T, U>(t, u);
}
Uygulama çalıştırıldığında ekran görüntüsü aşağıdaki gibi gerçekleşmektedir:
Film-Noir: 2
Action: 36
Adventure: 26
Horror: 19
War: 18
Romance: 56
History: 12
Western: 7
Documentary: 1
Sport: 3
Sci-Fi: 15
Drama: 180
Thriller: 70
Music: 7
Crime: 41
Fantasy: 14
Biography: 20
Family: 12
Animation: 9
Mystery: 33
Comedy: 60
Musical: 2
Yukarıdaki tüm çözümleri kolaylıkla paralelleştirebiliriz. İlk olarak yapılması gereken paralel Stream yaratmaktır. Bunun için Collection arayüzündeki parallelStream() çağrısı kullanılır:
Comparator<Country> byPopulation
        = (left, right) -> left.getPopulation() >= right.getPopulation() ? -1 : +1;
List<Country> overPopulatedCountries
        = worldDao.findAllCountries()
        .parallelStream()
.sorted(byPopulation)
        .limit(10)
        .collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
Burada dikkat edilmesi gerek en önemli nokta collect çağrısının üçüncü parametresidir. Paralel Stream kullanıldığı durumda çekirdek sayısı kadar iş hattı yaratılır ve her bir çekirdek torbada kendisine düşen kısımda çalışır ve sonuçlar en son birleştirilir. Birleştirilirken ArrayList::addAll metodu kullanılır. Bu teker teker eklemeye göre daha yüksek başarım verir.
Eğer torba üzerinde kümeleme işlemi yapılıyorsa, bu durumda kümeleme işleminin paralel olan versiyonu kullanılmalıdır. Örneğin, dokuzuncu problemde kümeleme olarak Collectors.groupingBy() metodu kullanılmıştır. Aynı problem, paralel olarak çözülmek istenirse, groupingBy metodu yerine groupingByConcurrent() metodu kullanılmalıdır:

private static <T, U> AbstractMap.SimpleEntry<T, U> pair(T t, U u) {
    return new AbstractMap.SimpleEntry<T, U>(t, u);
}

public static void main(String[] args) {
    MovieService movieService = InMemoryMovieService.getInstance();
    Collection<Movie> allMovies = movieService.findAllMovies();
    Map<String, List<Movie>> moviesByGenre
            = allMovies.parallelStream()
            .flatMap(movie -> movie.getGenres()
                             .stream().map(genre -> pair(genre.getName(), movie)))
            .collect(groupingByConcurrent(Entry::getKey, 
mapping(Entry::getValue, toList())));
    moviesByGenre.forEach((key, value) -> System.out.println(key + ": " + value.size()));
}

Eğer Eclipse Luna SR1 geliştirme aracını kullanıyorsanız, 8 ve 9 ile verilen kod için derleme hatası verecektir. Eclipse kendi derleyicisini kullanır: Eclipse Compiler for Java (ECJ). Ne yazık ki javac'ın derlediği kod için ECJ hata veriyor:
Exception in thread "main" java.lang.Error: Unresolved compilation problems:
Type mismatch: cannot convert from Map<Object,List<Object>> to Map<String,List<Movie>>
 The type Map.Entry does not define getKey(Object) that is applicable here
 The type Map.Entry does not define getValue(Object) that is applicable here
Bu hatanın Eclipse'in ilerleyen sürümlerinde düzeltilmesini umuyoruz. Kodun tamamına bu bağlantıdan NetBeans projesi olarak erişebilirsiniz.
Java 8 ile ilgili daha yoğun ve detay bilgi edinmek isterseniz bu bağlantıdaki eğitimleri incelemenizi öneririm. 
View Binnur Kurt's profile on LinkedIn

No comments:

Post a Comment